Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/modgraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
43 changes: 41 additions & 2 deletions modgraph/buildlist_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package modgraph

import (
"context"
"os"
"os/exec"
"path/filepath"
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
24 changes: 22 additions & 2 deletions modgraph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -308,6 +324,7 @@ type builder struct {
main *module
std *module
byPath map[string]*module
tools []importRef

queue []pkgRef
seen map[pkgRef]bool
Expand Down Expand Up @@ -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
}
Expand Down
35 changes: 35 additions & 0 deletions modgraph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
48 changes: 34 additions & 14 deletions modgraph/oracle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading