From eb015f7cf477f86dae190de009215f6374ea5695 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:19:55 +0000 Subject: [PATCH 1/2] feat(modgraph): read the tool directives of the main go.mod Build parsed the main go.mod with ParseLax, and ParseLax drops the tool lines. So the graph had no edge to the module of a tool. vet then read such a module as unused, but go tool builds and runs its code with the build list of the module. Build now parses the main go.mod as the go command does. It links each tool package as an import of the main module from go.mod. A tool that no module holds makes the main module open. Version is 2, because the same input gives another graph. The oracle test adds the tools of the go.mod to go list -deps. A synthetic test serves a tool module from a proxy on the disk and checks the graph against go list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFQbqPqd9xn1fT6hBsyoUm Signed-off-by: Claude --- docs/modgraph.md | 10 ++++++--- modgraph/buildlist_test.go | 43 ++++++++++++++++++++++++++++++++++++-- modgraph/graph.go | 24 +++++++++++++++++++-- modgraph/graph_test.go | 29 +++++++++++++++++++++++++ modgraph/oracle_test.go | 12 +++++++++-- 5 files changed, 109 insertions(+), 9 deletions(-) diff --git a/docs/modgraph.md b/docs/modgraph.md index e0256b22..5b203836 100644 --- a/docs/modgraph.md +++ b/docs/modgraph.md @@ -33,7 +33,11 @@ module, and a path that meets it reaches each module. 1. `Build` reads each package of the main module, its test files too, as `./...` names them. It skips `testdata`, `vendor`, a directory or file whose name starts with `_` or `.`, and a directory with a `go.mod` of its - own. A link to a file is a file of its package. + own. A link to a file is a file of its package. Each `tool` directive of + the main `go.mod` is an import of the main module from `go.mod`, because + `go tool` builds the tool with the build list of the module. `Build` + parses the main `go.mod` as the go command does, so a `go.mod` that the + go command rejects is an error. 2. For each import, the resolver takes the modules of the build list whose path is a prefix of the import path, longest first. A module with source holds the package when its directory has an entry whose name ends in @@ -165,8 +169,8 @@ full. ## Oracle test `TestBuildHoldsEachEdgeOfGoList` runs `go list -deps -test` on this -repository for four platforms and checks that the graph holds each module -edge, or that the edge leaves an open node. `MODGRAPH_ORACLE_DIRS` adds more +repository and the tools of its `go.mod` for four platforms. It checks that +the graph holds each module edge, or that the edge leaves an open node. `MODGRAPH_ORACLE_DIRS` adds more projects, and a project with `vendor/modules.txt` reads its dependencies from `vendor`. `TestBuildHoldsEachEdgeOfGoListInAWorkspace` puts this repository in a workspace with a probe module. For a workspace, and for a diff --git a/modgraph/buildlist_test.go b/modgraph/buildlist_test.go index 0b4e56e5..032fd69b 100644 --- a/modgraph/buildlist_test.go +++ b/modgraph/buildlist_test.go @@ -1,6 +1,7 @@ package modgraph import ( + "context" "os" "os/exec" "path/filepath" @@ -13,9 +14,11 @@ import ( modzip "golang.org/x/mod/zip" ) -// proxyModule is one module version of a test proxy, with its go.mod. +// proxyModule is one module version of a test proxy, with its go.mod and +// the imports of its one package. type proxyModule struct { path, version, gomod string + imports []string } // writeProxy writes a module proxy that a file:// GOPROXY serves: a list, @@ -29,7 +32,11 @@ func writeProxy(t *testing.T, dir string, mods []proxyModule) { at := filepath.Join(dir, filepath.FromSlash(esc), "@v") src := t.TempDir() name := m.path[strings.LastIndex(m.path, "/")+1:] - writeFiles(t, src, map[string]string{"go.mod": m.gomod, name + ".go": "package " + name + "\n"}) + code := "package " + name + "\n" + for _, imp := range m.imports { + code += "\nimport _ \"" + imp + "\"\n" + } + writeFiles(t, src, map[string]string{"go.mod": m.gomod, name + ".go": code}) writeFiles(t, at, map[string]string{ m.version + ".info": `{"Version":"` + m.version + `","Time":"2026-01-01T00:00:00Z"}`, m.version + ".mod": m.gomod, @@ -131,3 +138,35 @@ func TestDiscoverGivesTheBuildListOfTheGoCommand(t *testing.T) { }) } } + +// A tool directive names a package that go tool builds with the build list +// of the module. The graph reaches its module and the modules that it +// imports, as go list -deps tool does. +func TestBuildReachesTheModulesOfATool(t *testing.T) { + if testing.Short() { + t.Skip("the test runs the go command") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("no go command") + } + dir, proxy, cache := t.TempDir(), t.TempDir(), t.TempDir() + writeFiles(t, dir, map[string]string{ + "go.mod": "module example.com/a\n\ngo 1.24\n\nrequire (\n\texample.com/gen v1.0.0\n\texample.com/y v1.0.0\n)\n\ntool example.com/gen\n", + "a.go": "package a\n", + }) + gen := mod("example.com/gen", "v1.0.0", "example.com/y v1.0.0") + gen.imports = []string{"example.com/y"} + writeProxy(t, proxy, []proxyModule{gen, mod("example.com/y", "v1.0.0")}) + env := []string{"GOMODCACHE=" + cache, "GOPROXY=file://" + filepath.ToSlash(proxy), "GOSUMDB=off", "GONOSUMDB=*", "GOFLAGS=-modcacherw"} + _, err := goCommand(dir, env, "mod", "download", "all") + require.NoError(t, err) + sources, err := Discover(DiscoverOptions{Dir: dir, ModuleCache: cache}) + require.NoError(t, err) + + g, err := Build(context.Background(), Input{MainFS: os.DirFS(dir), Modules: sources}) + require.NoError(t, err) + assert.Equal(t, []string{"example.com/a -> example.com/gen", "example.com/gen -> example.com/y"}, edgeList(g)) + assert.True(t, node(t, g, "example.com/y").Reached) + assert.Equal(t, Evidence{File: "go.mod", Line: 10}, g.Edges[0].Evidence) + checkAgainstGoList(t, dir, sources, env, true) +} diff --git a/modgraph/graph.go b/modgraph/graph.go index da9fe07c..3d36f98d 100644 --- a/modgraph/graph.go +++ b/modgraph/graph.go @@ -30,7 +30,7 @@ import ( ) // Version changes when Build gives another graph for the same input. -const Version = "1" +const Version = "2" // StdPath is the module path of the node of the standard library. The // standard library imports no module of a build list, so the node is a leaf. @@ -260,7 +260,7 @@ func Build(ctx context.Context, in Input) (*Graph, error) { if err != nil { return nil, fmt.Errorf("modgraph: read the go.mod of the main module: %w", err) } - mf, err := modfile.ParseLax("go.mod", data, nil) + mf, err := modfile.Parse("go.mod", data, nil) if err != nil { return nil, fmt.Errorf("modgraph: parse the go.mod of the main module: %w", err) } @@ -277,9 +277,25 @@ func Build(ctx context.Context, in Input) (*Graph, error) { if err != nil { return nil, err } + b.tools = toolImports(mf) return b.run() } +// toolImports returns the tool directives of a go.mod as imports of the main +// module. The go command builds a tool with the build list of the module and +// runs it with go tool, so the module of a tool is a part of the build. +func toolImports(mf *modfile.File) []importRef { + out := make([]importRef, 0, len(mf.Tool)) + for _, t := range mf.Tool { + line := 0 + if t.Syntax != nil { + line = t.Syntax.Start.Line + } + out = append(out, importRef{Path: t.Path, File: "go.mod", Line: line}) + } + return out +} + // module is the state of one node during a build. type module struct { src Source @@ -308,6 +324,7 @@ type builder struct { main *module std *module byPath map[string]*module + tools []importRef queue []pkgRef seen map[pkgRef]bool @@ -363,6 +380,9 @@ func (b *builder) run() (*Graph, error) { if err := b.walkMain(); err != nil { return nil, err } + for _, t := range b.tools { + b.link(b.main, t) + } if err := b.drain(); err != nil { return nil, err } diff --git a/modgraph/graph_test.go b/modgraph/graph_test.go index acf2b662..bb826d0b 100644 --- a/modgraph/graph_test.go +++ b/modgraph/graph_test.go @@ -150,6 +150,35 @@ func TestBuildMarksTheTestOnlyEdgesOfTheMainModule(t *testing.T) { assert.False(t, node(t, g, "example.com/onlytests").Reached) } +func TestBuildReadsTheToolsOfTheMainModule(t *testing.T) { + cases := []struct { + name string + tool string + edges []string + open bool + }{ + {"a tool of a dependency", "example.com/gen/cmd/gen", []string{"example.com/app -> example.com/gen", "example.com/gen -> example.com/y"}, false}, + {"a package of the main module", mainPath, nil, false}, + {"a tool that no module holds", "example.com/missing/cmd", nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + g := build(t, Input{ + MainFS: withGoMod(fstest.MapFS{"main.go": goSrc("main")}, "module "+mainPath+"\n\ngo 1.24\n\ntool "+tc.tool+"\n"), + Modules: []Source{ + source("example.com/gen", fstest.MapFS{"cmd/gen/gen.go": goSrc("main", "example.com/y")}), + source("example.com/y", fstest.MapFS{"y.go": goSrc("y")}), + }, + }) + app := node(t, g, mainPath) + assert.Equal(t, tc.open, app.Open(), app.Reason) + if !tc.open { + assert.Equal(t, tc.edges, edgeList(g)) + } + }) + } +} + func TestBuildResolvesAnImportPath(t *testing.T) { cases := []struct { name string diff --git a/modgraph/oracle_test.go b/modgraph/oracle_test.go index 74cf2c81..41283800 100644 --- a/modgraph/oracle_test.go +++ b/modgraph/oracle_test.go @@ -246,12 +246,20 @@ type listPackage struct { } // goListEdges returns the module edges of the packages that go list -deps -// -test gives for a platform, and each import that go list resolves to no +// -test gives for a platform, for the packages of the module and its tools, and each import that go list resolves to no // module. go list -e gives such an import with no package of a module, so // it leaves no edge to compare. func goListEdges(t *testing.T, dir string, env []string, goos, goarch string) (map[[2]string]bool, []string) { t.Helper() - out, err := goCommand(dir, append(slices.Clone(env), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=1"), "list", "-e", "-deps", "-test", "-json", "./...") + args := []string{"list", "-e", "-deps", "-test", "-json", "./..."} + // The tool pattern of a workspace names the tools of each module of the + // workspace. The graph of a module holds the tools of its go.mod only. + mf, err := parseGoMod(filepath.Join(dir, "go.mod")) + require.NoError(t, err) + for _, tool := range mf.Tool { + args = append(args, tool.Path) + } + out, err := goCommand(dir, append(slices.Clone(env), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=1"), args...) require.NoError(t, err, "go list -deps") var pkgs []listPackage dec := json.NewDecoder(bytes.NewReader(out)) From 360855de7a2d5e088d464c1adb11081ed86612f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:27:39 +0000 Subject: [PATCH 2/2] test(modgraph): list tools without their tests, and pin the tool edges The oracle test named the tools of go.mod in the go list -deps -test call. -test applies to each named package, so go list gave the test imports of a tool of a dependency. The graph never reads the test files of a dependency, so the oracle could fail on an edge that no build has. The test now lists the tools in a second go list call with no -test. The case for a tool of the main module passed without the fix. It now names a package in testdata, which the walk of the main module skips. A test file of the main module also imports the tool, and the tool edge must not be test-only. A go.mod with a directive that the go command rejects is now an error, and a case pins it. The docs say that the graph of a workspace module holds only the tools of its own go.mod. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFQbqPqd9xn1fT6hBsyoUm Signed-off-by: Claude --- docs/modgraph.md | 8 +++++-- modgraph/graph_test.go | 14 ++++++++---- modgraph/oracle_test.go | 48 +++++++++++++++++++++++++---------------- 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/docs/modgraph.md b/docs/modgraph.md index 5b203836..aca87aff 100644 --- a/docs/modgraph.md +++ b/docs/modgraph.md @@ -145,6 +145,10 @@ a workspace. The caller finds the `go.work` file, as the go command does from list. Outside a workspace, `Discover` reads no `vendor` directory that holds this annotation. - `go.work.sum` and the `go.sum` of each module give the hashes. +- `go tool` in a workspace runs the tools of each module of the workspace. + The graph of a module holds the `tool` directives of its own `go.mod` + only. So a caller reads the graph of each module of the workspace before + it says that the workspace does not use a module. A wrong version can hide an edge. So `Discover` fails when it cannot know the build list: a `go.mod` that the graph needs is not on the disk, a module @@ -170,8 +174,8 @@ full. `TestBuildHoldsEachEdgeOfGoList` runs `go list -deps -test` on this repository and the tools of its `go.mod` for four platforms. It checks that -the graph holds each module edge, or that the edge leaves an open node. `MODGRAPH_ORACLE_DIRS` adds more -projects, and a project with `vendor/modules.txt` reads its dependencies +the graph holds each module edge, or that the edge leaves an open node. +`MODGRAPH_ORACLE_DIRS` adds more projects, and a project with `vendor/modules.txt` reads its dependencies from `vendor`. `TestBuildHoldsEachEdgeOfGoListInAWorkspace` puts this repository in a workspace with a probe module. For a workspace, and for a project of `MODGRAPH_ORACLE_DIRS` with a `go.work`, the test checks each diff --git a/modgraph/graph_test.go b/modgraph/graph_test.go index bb826d0b..c7777191 100644 --- a/modgraph/graph_test.go +++ b/modgraph/graph_test.go @@ -157,23 +157,28 @@ func TestBuildReadsTheToolsOfTheMainModule(t *testing.T) { edges []string open bool }{ - {"a tool of a dependency", "example.com/gen/cmd/gen", []string{"example.com/app -> example.com/gen", "example.com/gen -> example.com/y"}, false}, - {"a package of the main module", mainPath, nil, false}, + {"a tool of a dependency", "example.com/gen/cmd/gen", []string{"example.com/app -> example.com/gen", "example.com/app -> example.com/z (test)", "example.com/gen -> example.com/y"}, false}, + {"a tool in a directory that the walk of the main module skips", mainPath + "/testdata/gen", []string{"example.com/app -> example.com/gen (test)", "example.com/app -> example.com/y", "example.com/app -> example.com/z (test)", "example.com/gen -> example.com/y"}, false}, {"a tool that no module holds", "example.com/missing/cmd", nil, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { g := build(t, Input{ - MainFS: withGoMod(fstest.MapFS{"main.go": goSrc("main")}, "module "+mainPath+"\n\ngo 1.24\n\ntool "+tc.tool+"\n"), + MainFS: withGoMod(fstest.MapFS{ + "main.go": goSrc("main"), + "main_test.go": goSrc("main", "example.com/gen/cmd/gen", "example.com/z"), + "testdata/gen/gen.go": goSrc("main", "example.com/y"), + }, "module "+mainPath+"\n\ngo 1.24\n\ntool "+tc.tool+"\n"), Modules: []Source{ source("example.com/gen", fstest.MapFS{"cmd/gen/gen.go": goSrc("main", "example.com/y")}), source("example.com/y", fstest.MapFS{"y.go": goSrc("y")}), + source("example.com/z", fstest.MapFS{"z.go": goSrc("z")}), }, }) app := node(t, g, mainPath) assert.Equal(t, tc.open, app.Open(), app.Reason) if !tc.open { - assert.Equal(t, tc.edges, edgeList(g)) + assert.Equal(t, tc.edges, edgeList(g), "a tool edge is not test-only, though a test file imports the tool too") } }) } @@ -523,6 +528,7 @@ func TestBuildRejectsABadInput(t *testing.T) { {"the reserved std path as the main module", Input{MainFS: withGoMod(nil, "module std\n\ngo 1.22\n")}, "reserved"}, {"a module with no path", Input{MainFS: mainFS(nil), Modules: []Source{{}}}, "no path"}, {"the main module in the build list", Input{MainFS: mainFS(nil), Modules: []Source{source(mainPath, nil)}}, "twice"}, + {"a directive that the go command rejects", Input{MainFS: withGoMod(nil, "module "+mainPath+"\n\ngo 1.22\n\nfuture x\n")}, "parse the go.mod"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/modgraph/oracle_test.go b/modgraph/oracle_test.go index 41283800..34e1a2a2 100644 --- a/modgraph/oracle_test.go +++ b/modgraph/oracle_test.go @@ -246,31 +246,25 @@ type listPackage struct { } // goListEdges returns the module edges of the packages that go list -deps -// -test gives for a platform, for the packages of the module and its tools, and each import that go list resolves to no -// module. go list -e gives such an import with no package of a module, so -// it leaves no edge to compare. +// gives for a platform, and each import that go list resolves to no +// module. It lists the packages of the module with their tests, and the +// tools of its go.mod without their tests. go list -e gives an import that +// no module holds with no package of a module, so it leaves no edge to +// compare. func goListEdges(t *testing.T, dir string, env []string, goos, goarch string) (map[[2]string]bool, []string) { t.Helper() - args := []string{"list", "-e", "-deps", "-test", "-json", "./..."} + env = append(slices.Clone(env), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=1") + pkgs := goListDeps(t, dir, env, "-test", "./...") // The tool pattern of a workspace names the tools of each module of the // workspace. The graph of a module holds the tools of its go.mod only. mf, err := parseGoMod(filepath.Join(dir, "go.mod")) require.NoError(t, err) - for _, tool := range mf.Tool { - args = append(args, tool.Path) - } - out, err := goCommand(dir, append(slices.Clone(env), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=1"), args...) - require.NoError(t, err, "go list -deps") - var pkgs []listPackage - dec := json.NewDecoder(bytes.NewReader(out)) - for { - var p listPackage - if err := dec.Decode(&p); errors.Is(err, io.EOF) { - break - } else { - require.NoError(t, err) + if len(mf.Tool) > 0 { + var tools []string + for _, tool := range mf.Tool { + tools = append(tools, tool.Path) } - pkgs = append(pkgs, p) + pkgs = append(pkgs, goListDeps(t, dir, env, tools...)...) } moduleOf := map[string]string{} for _, p := range pkgs { @@ -302,6 +296,24 @@ func goListEdges(t *testing.T, dir string, env []string, goos, goarch string) (m return edges, dropped } +func goListDeps(t *testing.T, dir string, env []string, args ...string) []listPackage { + t.Helper() + out, err := goCommand(dir, env, append([]string{"list", "-e", "-deps", "-json"}, args...)...) + require.NoError(t, err, "go list -deps") + var pkgs []listPackage + dec := json.NewDecoder(bytes.NewReader(out)) + for { + var p listPackage + if err := dec.Decode(&p); errors.Is(err, io.EOF) { + break + } else { + require.NoError(t, err) + } + pkgs = append(pkgs, p) + } + return pkgs +} + func goCommand(dir string, env []string, args ...string) ([]byte, error) { cmd := exec.Command("go", args...) cmd.Dir = dir