DEV Community

Cover image for Signature Equality Is Not Behavioural Equality: Building a Dependency Migrator With Zero Dependencies
Prince Panchani
Prince Panchani

Posted on

Signature Equality Is Not Behavioural Equality: Building a Dependency Migrator With Zero Dependencies

A go.mod file tells you what your project depends on. It cannot tell you which of those dependencies the standard library has already made unnecessary.

That gap is bigger than it sounds. Go 1.13 shipped %w and github.com/pkg/errors became largely redundant. Go 1.21 shipped slices, maps, cmp and log/slog. Go 1.22 taught net/http.ServeMux method and wildcard routing. Go 1.27 shipped uuid. Every one of those releases quietly demoted a package that thousands of go.mod files still require.

Almost nobody goes back and removes them. Not out of laziness — because doing it safely means auditing which symbols you actually use, and whether the standard library's version really behaves the same. That's mechanical, tedious, high-stakes work. So I built a tool for it, for the Zero Dependency Hackathon 2026, Track A.

I went in believing this was an import-rewriting problem.

I was wrong, and the way I was wrong is the interesting part.

molt finds the dependencies Go's standard library has already replaced, and rewrites the ones it can prove are safe. It has no third-party dependencies. Its go.mod has no require block at all.

Here's the tool, the proof, and the build, in five minutes:


First: a static-analysis tool that can't use x/tools

The hackathon's rule for Go is unusually sharp:

stdlib only. go.mod has no require block (the toolchain and golang.org/x are not a free pass, stdlib means stdlib).

That last clause is the whole game. Every Go static-analysis tool — every linter, every code generator, every language server — loads source through golang.org/x/tools/go/packages. It is the canonical answer and it is excellent. It is also not the standard library.

molt's core question is: which exported names of package P does this file reference?

I assumed that needed type resolution, which meant go/packages, which meant the project was impossible under the rules. Then I actually looked at what I was asking for.

Import declarations and selector expressions are both syntax. They're already in the parse tree.

af, _ := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)

for _, spec := range af.Imports {
    // local name -> import path
}

ast.Inspect(af, func(n ast.Node) bool {
    if sel, ok := n.(*ast.SelectorExpr); ok {
        if id, ok := sel.X.(*ast.Ident); ok {
            // id.Name qualifies sel.Sel.Name
            // e.g. "uuid" qualifies "New"
        }
    }
    return true
})
Enter fullscreen mode Exit fullscreen mode

That's it. That's the analysis core. go/parser, go/ast, go/token, go/format — all standard library, all shipped with the compiler you already have.

Go's standard library contains a Go parser. That is not a coincidence or a curiosity. It is what a good standard library is for, and it's the only reason this project could exist under the constraint.

So the tool whose job is removing dependencies from Go projects turned out not to need any. I'd like to claim I planned the symmetry.


Then: the part I got wrong

Here's the naive model I started with.

import "golang.org/x/exp/slices"      import "slices"
Enter fullscreen mode Exit fullscreen mode

Same package name. Same function names. Swap the path, done.

Now look at what actually changed between those two packages:

// golang.org/x/exp/slices
slices.SortFunc(items, func(a, b Item) bool {
    return a.Score < b.Score          // less(a, b) bool
})

// standard library slices
slices.SortFunc(items, func(a, b Item) int {
    return cmp.Compare(a.Score, b.Score)   // cmp(a, b) int
})
Enter fullscreen mode Exit fullscreen mode

The comparator's return type changed from bool to int.

Swap only the import and pass the old closure, and Go's type checker will often accept it — a bool-returning closure is a compile error, but the failure mode people actually hit is subtler: code that was written against one convention and mechanically moved to the other. false is not 0. A comparator that returns bool-ish semantics through an int signature sorts your data into the wrong order.

It compiles. It runs. It's wrong. No panic, no error return, no log line. Just quietly incorrect ordering somewhere downstream.

That was the moment the project changed shape. The dangerous part of dependency migration isn't finding packages with matching names. It's deciding whether two APIs are behaviourally equivalent — and names are almost no evidence for that.

The trap table

Once I started looking for these, they were everywhere. Each row is pinned by a test in the repo:

Looks like a rename What actually changed
x/exp/slices.SortFuncslices.SortFunc Comparator went from less(a,b) bool to cmp(a,b) int. Compiles, then sorts wrongly.
x/exp/slices.SortStableslices.SortStable Doesn't exist. The stdlib only has SortStableFunc. Fails to compile.
x/exp/maps.Keysmaps.Keys Return type went from a slice to an iter.Seq. Needs slices.Collect.
google/uuid.Niluuid.Nil A package variable in google/uuid, a function in the stdlib. Must become uuid.Nil().
google/uuid.NewRandomuuid.NewV4 google returns (UUID, error); the stdlib returns UUID alone. The arity of the call site changes.
pkg/errors.Wrap(err, msg) Becomes fmt.Errorf("%s: %w", msg, err). The arguments swap places.

Look at uuid.Nil for a second. In github.com/google/uuid it's a package-level variable. In Go 1.27's uuid it's a function. So:

if id == uuid.Nil { }     // google/uuid — comparing to a variable
if id == uuid.Nil() { }   // stdlib      — calling a function
Enter fullscreen mode Exit fullscreen mode

I only found that because I ran go doc uuid against a real Go 1.27 toolchain instead of trusting a summary of the release notes. Signatures matter more than names when you're about to edit somebody else's code.


The bugs were in the migrations I thought were obvious

This is the section I'd skip if I were writing marketing copy, so it's the one worth reading.

After the first working version, I put the source through an automated code review. It came back with things I'd have sworn were fine. Eight of them were real, and fixing them made every headline number in my README smaller.

1. go-homedir — identical signatures, different behaviour

homedir.Dir() and os.UserHomeDir() both return (string, error). Byte-identical signature. I had it marked mechanical, and the tool rewrote it happily.

Then: go-homedir caches its first result by default. os.UserHomeDir reads the environment on every call.

For most code that difference is invisible. But go-homedir exports Reset() and DisableCache(), and code that calls either of those is code that depends on the caching. A file that only calls Dir() looks perfectly safe to rewrite in isolation — and if a sibling file in the same package calls Reset(), rewriting the first one silently breaks an assumption the package was built on.

molt decides eligibility per file, which is deliberate and mostly a feature: one awkward call site shouldn't disqualify eighty clean ones. But per-file analysis structurally cannot see across files. So this row can't be mechanical, and it's now advisory with a note explaining exactly why.

Signature equality is necessary for a mechanical rewrite. It was never sufficient.

2. pkg/errors.New — not a rename, a feature removal

I had this one wrong in the most embarrassing way, because it's the migration everyone assumes is trivial:

errors.New("boom")   // pkg/errors — captures a retrievable stack trace
errors.New("boom")   // stdlib     — does not
Enter fullscreen mode Exit fullscreen mode

Same call, same signature, same result type. pkg/errors.New attaches a stack trace you can retrieve later. errors.New doesn't. Same for pkg/errors.Errorf versus fmt.Errorf.

That's not a rename. It's removing a feature from a codebase that may be relying on it — and doing it invisibly, because nothing fails until someone goes looking for a stack trace that isn't there any more.

Of pkg/errors, only Is, As and Unwrap are genuinely drop-in. New and Errorf are now blocked. Wrap and Wrapf always needed hands.

The cost of being right: ory/kratos has 1,785 pkg/errors uses across 286 files. My earlier pass called 40 of them migratable. After this fix, 11.

3. The Go-version gate I'd never written

molt would happily rewrite github.com/google/uuid to the standard library's uuid — which landed in Go 1.27.

gofiber/fiber declares go 1.24. minio/minio declares go 1.25.

Rewriting their imports would have produced code that references a standard-library package their own declared toolchain floor doesn't provide. It wouldn't compile. I was generating broken code and calling it a migration.

The fix is a module-level veto that runs before any file is touched:

// Ineligible reports why a module-level fact makes m unsafe to apply
// automatically to mod, regardless of per-file symbol usage.
func Ineligible(mod *gomod.File, m corpus.Migration) string {
    if mod != nil {
        if rep, ok := mod.Replaced(m.Module); ok {
            return fmt.Sprintf("go.mod replaces this module with %s; "+
                "corpus verification does not apply to the replacement", rep.New)
        }
    }
    if !gomod.GoVersionAtLeast(mod.GoVersion, m.Since) {
        return fmt.Sprintf("requires %s; module declares go %s",
            m.Since, mod.GoVersion)
    }
    return ""
}
Enter fullscreen mode Exit fullscreen mode

A module with no go directive is treated as satisfying nothing above go1.0. An unknown floor can't be confirmed to be high enough, and guessing in the permissive direction generates code that doesn't build.

4. replace directives, and the prefix that nearly slipped through

If go.mod says:

replace golang.org/x/exp => ../our-fork
Enter fullscreen mode Exit fullscreen mode

then the code behind golang.org/x/exp/slices is not the code my corpus verified. It could be a local fork with different behaviour entirely.

The subtlety: a replace operates on a module path, and a module contains many packages. That directive never mentions slices, but it redirects it. Matching import paths for equality misses it completely — you need the prefix too:

func (f *File) Replaced(importPath string) (Replace, bool) {
    for _, r := range f.Replaces {
        if r.Old == importPath || strings.HasPrefix(importPath, r.Old+"/") {
            return r, true
        }
    }
    return Replace{}, false
}
Enter fullscreen mode Exit fullscreen mode

Which also meant writing a real replace parser — single-line and parenthesised block forms — where I'd previously just counted the directive and moved on.

5. Qualifier collisions, checked before mutating anything

An unaliased rewrite introduces a new qualifier at every call site: the target package's own name. If the file already binds that name — a variable called slices, or an import of the same path under a different alias — the rewrite corrupts the file.

I had shadowing detection. I didn't have this:

if q, imported := qualifierFor(af, t); imported && q != want {
    return nil, fmt.Errorf("%s is already imported as %q in this file, "+
        "which conflicts with the unaliased %q this migration needs", t, q, want)
}
Enter fullscreen mode Exit fullscreen mode

And critically, that check now runs before a single AST node is mutated. The earlier version could bail halfway through and leave a file with some selectors renamed and some not — worse than either outcome.

6. A stale snapshot

rewrite collected the file's existing imports once, up front, then applied migrations in a loop. But an earlier migration in that same loop can add or remove an import. Every subsequent migration was reasoning about a snapshot that was already wrong. Now it queries live.

7. Non-atomic writes

os.WriteFile truncates before it writes. A crash or a full disk mid-write leaves the user's source file truncated — the worst possible failure for a tool that edits code.

// writeFileAtomic writes data to path without ever leaving it half-written.
// Temp file in the same directory, sync, then rename — atomic on POSIX and
// Windows both, so a crash mid-write leaves the original intact.
func writeFileAtomic(path string, data []byte, mode os.FileMode) (err error) {
    tmp, err := os.CreateTemp(filepath.Dir(path), ".molt-*.tmp")
    // ... write, Sync, Close, Chmod ...
    return os.Rename(tmpPath, path)
}
Enter fullscreen mode Exit fullscreen mode

The temp file goes in the same directory on purpose, so the rename can't cross a filesystem boundary and silently degrade to a copy.

8. A swallowed error

A file that couldn't be read was logged to stderr and skipped, and the run still exited 0. So -apply could report success having silently skipped half your files. Read failures now fail the run.


Nine tests, one review

Every one of those fixes has a test that fails if it regresses: TestVersionGateBlocksNewerMigration, TestReplaceDirectiveVetoesMigration, TestRefusesTargetQualifierCollision, TestRefusesReuseOfIncompatibleQualifier, TestRewriteReportsReadFailures, TestApplyWritesAtomicallyAndCleansUp, and an expanded TestTrapsArePinned that now pins pkg/errors.New/Errorf and slices.SortStable as blocked.

The corpus went from 6 mechanical rows to 5. ory/kratos went from 40 migratable files to 11. minio/minio and gofiber/fiber each lost their google/uuid migration to the version gate.

Every number got worse, and the tool got correct. If you're building anything that edits source code, that trade is not close.


What molt refuses to do

Which brings me to the design principle I'd defend hardest:

Automation should stop when confidence stops.

molt edits source code, so the interesting question isn't what it can do. Every migration in the corpus is one of two kinds:

  • Mechanical — verified behaviour-preserving at every call site it permits, symbol by symbol. molt rewrites these. There are 5, out of 24 rows.
  • Advisory — the migration is real, but it changes the shape of the code rather than its names. logrus.WithFields(...) to slog attributes. A gorilla/mux route table to ServeMux patterns. molt explains it and leaves it alone.

On top of that, molt declines to touch a file when:

                    file imports a corpus module
                              │
                    ┌─────────┴─────────┐
              mechanical?            advisory ──▶ explain, don't touch
                    │
              dot import? ──────────yes──────────▶ REFUSE
                    │                    (selectors unattributable)
        package name shadowed? ────yes──────────▶ REFUSE
                    │                    (might rewrite wrong identifier)
      qualifier collision / alias? ─yes──────────▶ REFUSE
                    │
       every symbol in the table? ──no───────────▶ REFUSE
                    │                    (no guessing)
        go.mod version high enough? ─no──────────▶ REFUSE
                    │
          replace directive? ──────yes───────────▶ REFUSE
                    │                    (unverified code)
              rewrite in memory
                    │
         output re-parses & formats? ─no─────────▶ ABORT
                    │                    (file left byte-identical)
                  WRITE
Enter fullscreen mode Exit fullscreen mode

That last one matters more than it looks: molt parses its own output and refuses to write anything the parser rejects. A tool that puts unparseable Go into your repository is worse than no tool.

And eligibility is decided per file rather than per module, because a project may use one awkward symbol in one place and clean ones in eighty others. That's why reports say things like "21 of 29 files" rather than a yes/no.

The refusals aren't hypothetical. Running against 12 production repositories, the dot-import defence fired on sirupsen/logrus and the shadowing defence fired on spf13/viper — real code, not fixtures. docker/cli imports pkg/errors, but only inside vendor/, which molt skips exactly as the go command does; it correctly reported nothing.


Proving zero dependencies

Plenty of projects claim no dependencies. The claim is worth more if a reader can falsify it in one command.

Here's molt's entire go.mod:

module molt

go 1.25
Enter fullscreen mode Exit fullscreen mode

No require block. No go.sum file. No vendor/ directory.

And the check anyone can run:

go list -deps ./... | grep -v '^molt' | awk -F/ '$1 ~ /\./'
Enter fullscreen mode Exit fullscreen mode

In plain English: list every package in the build, drop molt's own, and show me anything left that looks like it came from the internet.

The technical version: go list -deps prints the full transitive package graph. Every module path outside the standard library begins with a domain name, so a dot in the first path element is a reliable test for "not stdlib". fmt has no dot. go/ast has no dot. github.com/anything does.

The output is empty. The build is 91 packages: 83 standard library, 8 of molt's own, 0 third-party.

That's the same test goimports uses internally to sort standard-library imports into their own group, which I found out when I had to reimplement import grouping — gofmt doesn't group imports, and goimports is a separate binary, not a library I could call.

The 14 packages I didn't install

The repo's STDLIB.md documents every substitution with what got harder and what tradeoff was accepted. A few that were more interesting than expected:

Instead of I used The catch
x/tools/go/packages go/parser + go/ast No type resolution. Handled by refusing ambiguous cases, not resolving them.
x/tools/go/ast/astutil direct *ast.GenDecl edits go/printer only emits parentheses when Lparen holds a valid position
x/mod/modfile ~200 lines of hand-written parser Quoted paths, // indirect followed by other words, block directives
sergi/go-diff an LCS line differ O(n×m) memory — fixed by trimming common prefix/suffix first
spf13/cobra flag Lost shell completion. molt takes one path and seven booleans.
stretchr/testify testing More typing — and better failure messages, unexpectedly
Masterminds/semver nothing I never actually needed to compare versions

That last row is my favourite. I assumed reporting "stdlib since go1.21" meant comparing versions. It didn't — Since was just a display string, and the decision molt makes depends on the corpus, not version arithmetic. The most valuable substitution is the one where you realise you didn't need the capability at all.

(Ironically, the Go-version gate from the code review later did need version comparison. It's 30 lines of strings.SplitN and strconv.Atoi, because go.mod's go directive has only ever gated stdlib availability at minor-version granularity. Still not a semver library.)


The edge case that ate an afternoon

Two lines of go/printer behaviour, and I want to be specific about it because it's the kind of thing you cannot find by reasoning — only by staring at wrong output.

molt was rewriting slices.Sort(s) correctly, in the sense that the AST was right and the code compiled. It printed like this:

slices.
    Sort(s)
Enter fullscreen mode Exit fullscreen mode

Every rewritten call site, split across two lines. Valid Go. Completely unacceptable — nobody accepts a patch that looks like that.

I assumed I'd broken the selector expression. I hadn't. The AST was perfect. The problem was the positions.

Here's what I'd written:

c.sel.X = ast.NewIdent(pkg)   // replace the qualifier node
Enter fullscreen mode Exit fullscreen mode

ast.NewIdent creates an identifier carrying token.NoPos — position zero. And go/printer doesn't lay out from structure alone; it reads the gap between a node's recorded position and the next one to decide where line breaks go. A zero-position qualifier followed by a selector at its real position in a 400-line file looks, to the printer, like an enormous vertical gap. So it inserts a newline.

The fix is one character of difference in intent:

c.sel.X.(*ast.Ident).Name = pkg   // mutate the existing node's Name
Enter fullscreen mode Exit fullscreen mode

Don't replace the node. Reach into the node that's already there and change its Name field, so the original position survives untouched.

Two lessons I'd have paid to learn faster:

  1. go/ast nodes are not pure data. They carry token.Pos fields that the printer treats as layout instructions. Synthesising a node is not the same as editing one, and the difference doesn't show up until you print.
  2. This is exactly the class of problem x/tools/go/ast/astutil exists to hide. Not having it meant learning why it exists. That afternoon was the single clearest illustration of what the zero-dependency constraint actually costs — and what it teaches.

Its sibling, from the same afternoon: go/printer only emits parentheses around an import block when GenDecl.Lparen holds a valid position. A single-line import "x" that gains a second spec prints as one broken line unless you promote it first:

if !gen.Lparen.IsValid() {
    gen.Lparen = gen.TokPos + token.Pos(len("import"))
    gen.Rparen = gen.Lparen
}
Enter fullscreen mode Exit fullscreen mode

That's a fabricated position, and fabricating positions is fragile enough that I stopped doing it for anything larger. It's why import grouping is done by splicing bytes into the printed output rather than by manipulating the tree — forcing a blank line between two specs through go/printer means inventing token positions, and I'd already learned what happens when you get those wrong.


Reproducible builds, for the same reason

If the point of the project is removing hidden machinery, the build itself should be inspectable. make repro builds twice, clears the build cache in between, and compares SHA-256:

Target SHA-256
windows/amd64 89f9e03a4b0239a010ceece14535ec13a6a0fcb0bb4569da5828b3292fcddba4
linux/amd64 c59a5c5edb7003e7bef837fae717504789740b83bd87e2a21a324635c5e69852
darwin/arm64 2c08a9a71dabe63435be289281f81dfffe2da82ac7e45e06bc607435bacb81a5

Go builds are not byte-identical by default. Three things break it:

  1. Absolute source paths get embedded → -trimpath
  2. Since Go 1.24, the toolchain stamps VCS information into the binary — commit hash and dirty flag change the bytes → -buildvcs=false. This is the one most people miss.
  3. The build ID varies-ldflags "-buildid="

Plus CGO_ENABLED=0 to keep the host C toolchain out, and a pinned GOTOOLCHAIN so a different Go version can't silently change the output.

molt also embeds no build timestamp and no commit hash. A version string that changed every build would be worth less than a reproducible artifact.


What it actually looks like

$ molt testdata/tidy-app

molt github.com/example/tidy

  Go files scanned   2
  Direct requires    3
  Indirect requires  0

REMOVABLE molt can apply these in full

  github.com/google/uuid -> uuid
    stdlib since go1.27 · 1 symbol, 1 use, 1 file
    New

  golang.org/x/exp/slices -> slices
    stdlib since go1.21 · 3 symbols, 3 uses, 1 file
    Compact, Contains, Sort

  golang.org/x/net/context -> context
    stdlib since go1.7 · 2 symbols, 4 uses, 2 files
    Background, Context

  3 removable · 0 partly removable · 0 need a human · 0 unused
  corpus: 24 rows, 5 mechanical
Enter fullscreen mode Exit fullscreen mode

molt -diff . prints the patch without writing anything:

 import (
+   "context"
    "errors"
    "fmt"
    "path/filepath"
-
-   "github.com/google/uuid"
-   "golang.org/x/exp/slices"
-   "golang.org/x/net/context"
+   "slices"
+   "uuid"
 )
Enter fullscreen mode Exit fullscreen mode

Note that the import block comes back regrouped stdlib-first — that's the hand-rolled grouping, since gofmt won't do it.

molt -apply . writes, then tells you the next two commands. It never edits go.mod itself:

Rewrote 3 files. Run go mod tidy to drop the requires, then go test ./... to confirm.
Enter fullscreen mode Exit fullscreen mode

Rewriting the manifest is the go command's job and it does it better. -exit-code follows the gofmt -l convention so CI can fail on findings; plain molt . exits 0 even with findings, because reporting is not failing.


Limitations, stated plainly

These matter more than the feature list.

  1. Go only. The whole idea depends on the standard library shipping a parser.
  2. No type checking. molt matches import declarations against selector expressions, and handles the cases where that's insufficient by refusing them. A type-aware version would migrate more files and be a much larger tool.
  3. Shadowing detection is file-wide, not scope-aware. If a file binds slices anywhere, the whole file is unsafe. This over-reports and costs molt rewrites it could have made. The opposite error corrupts code.
  4. Build-tagged files aren't excluded. molt reads every .go file regardless of constraints, which is why the "unused dependency" finding is worded as a prompt to look, not a verdict.
  5. molt never edits go.mod.
  6. The corpus is hand-written and finite — 24 rows. It will miss dependencies it's never heard of. molt -corpus prints exactly what it knows.
  7. Mechanical wins are rarer than 24 rows suggests, and rarer still after the version gate. Well-maintained repos have mostly already left x/exp/slices and x/net/context. google/uuid is the most promising row and needs Go 1.27 — released days before this event — so most real modules don't qualify yet. That gate is doing its job.

And the one that matters most: molt does not claim your tests will pass after -apply. It claims the edit is behaviour-preserving for the symbols it permits, and that you should run your tests. Which is why the command tells you to.


What I actually learned

I set out to build an import rewriter and ended up building a confidence classifier.

The code that decides whether to rewrite is now larger and more interesting than the code that does the rewriting. That inversion happened because of the traps — SortFunc's comparator, uuid.Nil's variable-to-function change, pkg/errors quietly dropping stack traces, go-homedir caching where the standard library doesn't. Every one of them looks like a rename. None of them is.

The constraint helped more than it hurt. Not having go/packages meant I couldn't resolve my way out of ambiguity, so I had to classify it instead — and the refusals turned out to be the most valuable thing in the tool. A type-aware version would migrate more files. I'm not sure it would have taught me that.

And the code review that made every number smaller was the best thing that happened to the project. It's an easy principle to state and a hard one to accept while you're watching "40 migratable files" become "11".

A dependency isn't automatically bad. But a dependency the platform has already replaced is worth questioning — and the goal was never to reach zero. It was to make the decision deliberate.

Your go.mod is a record of the last time you checked what the standard library could do. Mine is three lines long, and I can prove it in one command.


Code: github.com/PrinceXDev/molt — MIT, go build -o molt ./cmd/molt, no downloads.
Demo film: five minutes, all real output.
Hackathon: Zero Dependency 2026, Track A — Developer Tools & CLI.

If you work on Go tooling, or you've hit the go/printer position problem yourself, I'd genuinely like to hear how you handled it — Prince Panchani on LinkedIn.

Written for the Zero Dependency 2026 Write-Up side quest. Thanks to Hackathon Raptors for running an event whose central constraint turned out to be a design tool.

Top comments (4)

Collapse
 
alexshev profile image
Alex Shev

This is a useful case for making the migrator’s confidence observable. For every proposed rewrite, emitting the matched rule, the behavior it assumes, the package-wide facts it did and did not inspect, and a suggested regression test would let a maintainer review risk rather than accept a binary “safe.” That is especially valuable for silent semantic changes.

Collapse
 
prince_panchani_f971a20ec profile image
Prince Panchani

Thanks, @alexshev — I completely agree. That’s exactly the direction I want to take with molt: make the reasoning behind each rewrite observable, not just label it as “safe.” Showing the matched rule, assumed behaviour, inspection boundaries, and a suggested regression test would make the migration decisions much easier to review, especially for changes where the code still compiles but the behaviour can silently change.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The go-homedir finding is more general than the row you demoted, and the general form is computable rather than discovered by review. Per-file eligibility is sound exactly when the behavioural difference is determined by the call site's syntax. go-homedir breaks that because the old package carries package-level state and exports mutators for it, so a call in one file is qualified by a call in another. That is a property of the old package's API surface, not of the migration: any dependency exporting something like Reset, DisableCache, SetDefault or a package-level var the library writes to is a candidate for the same defect, whatever its signatures look like. You already parse Go with the stdlib, so the audit is the same shape as the rest of the tool — walk the old package's exported declarations, flag package-level vars plus any exported func that assigns to one, and let a hit force the row to advisory before a human has to notice it. That turns "eight things a review found" into a gate that catches the ninth.

Separately, Ineligible reads two module-level facts and there is a third with the same standing. replace and the go directive are both in scope, but the require version of the old module is not, and the corpus rows are statements about a specific version of that module's API — x/exp in particular is not a stable surface, so which side of a trap a project sits on can depend on the version it pins rather than on the import path. I have not checked which of your rows actually move across x/exp versions, so this may be empty for the current corpus, but it is the same class as the veto you already built: a module-level fact that decides whether the verified corpus applies at all, and the permissive default is the one that generates code you did not verify.

Collapse
 
prince_panchani_f971a20ec profile image
Prince Panchani

That’s a fair point @vinhnguyenthanhdn. I agree the go-homedir issue should be generalised beyond the specific row that was demoted.

The package-level state/mutator pattern is a better eligibility signal than relying on individual call-site signatures. I’ll update the audit to inspect the old package’s exported declarations for package-level state and exported functions that can mutate it, and treat those findings as advisory before generating a migration. That should make the check systematic rather than dependent on manually discovered cases.

I also agree on the require version. Since the corpus is verified against a specific module API surface, the pinned version can affect whether those assumptions hold, especially for something like x/exp. I’ll include the old module’s required version as another module-level eligibility fact and make the default conservative when the corpus doesn’t cover that version.

Both changes fit the existing stdlib AST/module analysis approach and should help turn these review findings into preventative gates rather than one-off fixes.