diff --git a/docs/modgraph.md b/docs/modgraph.md index e0256b2..aca87af 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 @@ -141,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 @@ -165,9 +173,9 @@ 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 -projects, and a project with `vendor/modules.txt` reads its dependencies +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 project of `MODGRAPH_ORACLE_DIRS` with a `go.work`, the test checks each diff --git a/modgraph/buildlist_test.go b/modgraph/buildlist_test.go index 0b4e56e..032fd69 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 da9fe07..3d36f98 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 acf2b66..c777719 100644 --- a/modgraph/graph_test.go +++ b/modgraph/graph_test.go @@ -150,6 +150,40 @@ 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/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"), + "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), "a tool edge is not test-only, though a test file imports the tool too") + } + }) + } +} + func TestBuildResolvesAnImportPath(t *testing.T) { cases := []struct { name string @@ -494,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 74cf2c8..34e1a2a 100644 --- a/modgraph/oracle_test.go +++ b/modgraph/oracle_test.go @@ -246,23 +246,25 @@ 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 -// 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() - out, err := goCommand(dir, append(slices.Clone(env), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=1"), "list", "-e", "-deps", "-test", "-json", "./...") - 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) + 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) + 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 { @@ -294,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