From fd09dd86f242892a8d4a4a9eaf460f03adff153f Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 15 Oct 2026 07:24:47 +0000 Subject: [PATCH] tools/bump: upgrade modules behind tool directives x/tools is indirect, so never moved; v0.49.0 cannot read Go 1.27.2 export data and viewer emits invalid types, failing go generate. --- tools/bump/gomod.go | 26 +++++++++++++++++++++++-- tools/bump/gomod_test.go | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/tools/bump/gomod.go b/tools/bump/gomod.go index 1bec7acc..34cb8711 100644 --- a/tools/bump/gomod.go +++ b/tools/bump/gomod.go @@ -166,7 +166,9 @@ func sqliteAtom(ctx context.Context, r *repo) (string, error) { } // directRequirements is every direct requirement not already owned by a -// lockstep atom. +// lockstep atom, plus the modules behind go.mod's tool lines. Those are marked +// indirect when nothing imports them, yet go generate runs them, and a stale +// golang.org/x/tools cannot read the export data a newer Go writes. func directRequirements(r *repo) ([]string, error) { f, err := parseGoMod(r) if err != nil { @@ -181,10 +183,15 @@ func directRequirements(r *repo) ([]string, error) { modLibc: true, } + tools := map[string]bool{} + for _, t := range f.Tool { + tools[toolModule(f, t.Path)] = true + } + var paths []string for _, req := range f.Require { - if req.Indirect || owned[req.Mod.Path] { + if owned[req.Mod.Path] || (req.Indirect && !tools[req.Mod.Path]) { continue } @@ -194,6 +201,21 @@ func directRequirements(r *repo) ([]string, error) { return paths, nil } +// toolModule finds the required module providing pkg. The longest match wins, +// since a nested module shadows its parent's tree. +func toolModule(f *modfile.File, pkg string) string { + var best string + + for _, req := range f.Require { + p := req.Mod.Path + if (pkg == p || strings.HasPrefix(pkg, p+"/")) && len(p) > len(best) { + best = p + } + } + + return best +} + // moduleAtom upgrades one direct requirement. One atom per module costs nothing // on a good day, because the bisect tries every atom together first and only // splits when that fails. It is what stops a single dependency deprecating an diff --git a/tools/bump/gomod_test.go b/tools/bump/gomod_test.go index 64afe0be..cc13a36d 100644 --- a/tools/bump/gomod_test.go +++ b/tools/bump/gomod_test.go @@ -1,6 +1,9 @@ package main import ( + "os" + "path/filepath" + "slices" "testing" "golang.org/x/mod/modfile" @@ -117,3 +120,41 @@ func TestRepoLockstepNotesAttached(t *testing.T) { t.Error("go.mod: tool block is missing") } } + +// A tool's module is indirect in go.mod, but it still has to move: go generate +// runs it against whatever Go the flake ships. +func TestDirectRequirementsIncludesTools(t *testing.T) { + const content = `module example.com/x + +go 1.27.0 + +require ( + github.com/go-chi/chi/v5 v5.4.0 + golang.org/x/net v0.58.0 // indirect + golang.org/x/tools v0.49.0 // indirect + tailscale.com v1.100.0 +) + +tool ( + golang.org/x/tools/cmd/stringer + tailscale.com/cmd/viewer +) +` + + dir := t.TempDir() + + err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(content), 0o600) + if err != nil { + t.Fatal(err) + } + + got, err := directRequirements(&repo{Root: dir}) + if err != nil { + t.Fatal(err) + } + + want := []string{"github.com/go-chi/chi/v5", "golang.org/x/tools"} + if !slices.Equal(got, want) { + t.Errorf("directRequirements = %v, want %v", got, want) + } +}