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
49 changes: 47 additions & 2 deletions docs/modgraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ module of the build.
```go
sources, err := modgraph.Discover(modgraph.DiscoverOptions{
Dir: projectDir, // the directory of the main go.mod
Work: workFile, // the go.work that uses it, or "" outside a workspace
ModuleCache: moduleCache, // GOMODCACHE; the caller finds it
LocalRoot: scanRoot, // a local replace directive is read only inside it
})
Expand Down Expand Up @@ -109,6 +110,44 @@ a replaced module, and `Source.Origin` is the module that the source holds.
`replace` directives. A directive that `golang.org/x/mod` does not know
fails the parse, and the caller keeps no graph.

## Workspaces

With `DiscoverOptions.Work`, `Discover` gives the build list of the
workspace for the module in `Dir`, as the go command builds each module of
a workspace. The caller finds the `go.work` file, as the go command does from
`GOWORK` and the parents of the module directory.

- The build list is the result of minimal version selection with module
graph pruning, as `readModGraph` of the go command builds it. The graph
holds the requirements of each module of the workspace and of the
`go.mod` of each such requirement. A `go.mod` below go 1.17, a module of
the workspace too, adds the requirements of each `go.mod` that it
requires, and so on down the chain. Then, while a module that the build
needs requires a module below its selected version, the `go.mod` of the
selected version joins the graph with each `go.mod` that it requires.
Each path takes its highest version in the graph.
- A requirement on a module of the workspace at a version reads the
`go.mod` of that version. The build uses the code of the workspace.
- Each other module of the workspace is a local source at no version. Its
code is read inside `LocalRoot`, and `Source.Origin` holds its directory.
- A `replace` directive of `go.work` wins. Else the `replace` directive of a
module of the workspace applies. Two modules that replace one module with
other code make `Discover` fail, as they make the go command fail.
- The go command reads the workspace from the `vendor` directory next to
`go.work` when the `go` directive of `go.work` is go 1.14 or later and
the first line of `vendor/modules.txt` holds the `## workspace`
annotation that `go work vendor` writes. Each module that `modules.txt`
lists with a package then comes from `vendor`, at the version of that
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.

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
of the workspace is outside `LocalRoot`, or a `go.mod` has an `exclude`
directive. The caller then keeps no graph. `go mod download` in the
workspace fetches each `go.mod` that the graph needs.

`examples/modgraph` prints the graph of a module. Run `go mod download`
first: a module with no source on the disk is open, and one open node that
the build reaches makes each module reached.
Expand All @@ -129,12 +168,18 @@ full.
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
from `vendor`. The test needs the `go` command and a module cache, and skips
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
module that the workspace uses. It also checks that `Discover` gives each
version of the build list that `go list -m all` gives.
`TestDiscoverGivesTheBuildListOfTheGoCommand` serves synthetic modules
from a proxy on the disk and compares `Discover` with `go list -m all` for
workspaces where the versions of two modules differ. The test needs the `go` command and a module cache, and skips
with neither. It sets `GOPROXY=off`, so it downloads nothing.

## Not covered

- `go.work` workspaces. A caller keeps no graph for a workspace.
- A file larger than 32 MiB whose first 64 KiB does not parse makes its
module open.
- A file budget, 200,000 files by default, caps a run. Past it the module
Expand Down
16 changes: 12 additions & 4 deletions examples/modgraph/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
// them. It finds the source of each module with modgraph.Discover in the
// vendor directory and in the module cache, which -modcache names, or
// $GOMODCACHE, or $GOPATH/pkg/mod. It runs no go command and downloads
// nothing. Run go mod download first for a graph with no open node.
// nothing. Run go mod download first for a graph with no open node. With
// -work, the build list is that of the workspace, and the modules of the
// workspace are read inside the directory of go.work.
//
// go run ./examples/modgraph -dir ../vet
// go run ./examples/modgraph -dir ../vet -work ../vet/go.work
// go run ./examples/modgraph -dir ../vet -json
package main

Expand All @@ -25,16 +28,21 @@ import (
func main() {
dir := flag.String("dir", ".", "the directory of the main go.mod")
modcache := flag.String("modcache", "", "the module cache, else $GOMODCACHE, else $GOPATH/pkg/mod, else $HOME/go/pkg/mod")
work := flag.String("work", "", "the go.work file of a workspace that uses the module")
asJSON := flag.Bool("json", false, "print the graph as JSON")
flag.Parse()
if err := run(os.Stdout, *dir, *modcache, *asJSON); err != nil {
if err := run(os.Stdout, *dir, *work, *modcache, *asJSON); err != nil {
fmt.Fprintln(os.Stderr, "modgraph:", err)
os.Exit(1)
}
}

func run(w io.Writer, dir, modcache string, asJSON bool) error {
sources, err := modgraph.Discover(modgraph.DiscoverOptions{Dir: dir, ModuleCache: moduleCache(modcache), LocalRoot: dir})
func run(w io.Writer, dir, work, modcache string, asJSON bool) error {
root := dir
if work != "" {
root = filepath.Dir(work)
}
sources, err := modgraph.Discover(modgraph.DiscoverOptions{Dir: dir, Work: work, ModuleCache: moduleCache(modcache), LocalRoot: root})
if err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions examples/modgraph/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ func TestRunPrintsTheGraph(t *testing.T) {
}

var out strings.Builder
require.NoError(t, run(&out, dir, cache, false))
require.NoError(t, run(&out, dir, "", cache, false))
assert.Contains(t, out.String(), "reached imports example.com/dep@v1.0.0")
assert.Contains(t, out.String(), "example.com/app -> example.com/dep (imports, main.go:3)")
assert.Contains(t, out.String(), "example.com/dep -> std (imports, d.go:3)")

out.Reset()
require.NoError(t, run(&out, dir, cache, true))
require.NoError(t, run(&out, dir, "", cache, true))
var g modgraph.Graph
require.NoError(t, json.Unmarshal([]byte(out.String()), &g))
dep, ok := g.Node("example.com/dep")
Expand Down
133 changes: 133 additions & 0 deletions modgraph/buildlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package modgraph

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gomodule "golang.org/x/mod/module"
modzip "golang.org/x/mod/zip"
)

// proxyModule is one module version of a test proxy, with its go.mod.
type proxyModule struct {
path, version, gomod string
}

// writeProxy writes a module proxy that a file:// GOPROXY serves: a list,
// an info file, a go.mod and a zip for each module version.
func writeProxy(t *testing.T, dir string, mods []proxyModule) {
t.Helper()
versions := map[string][]string{}
for _, m := range mods {
esc, err := gomodule.EscapePath(m.path)
require.NoError(t, err)
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"})
writeFiles(t, at, map[string]string{
m.version + ".info": `{"Version":"` + m.version + `","Time":"2026-01-01T00:00:00Z"}`,
m.version + ".mod": m.gomod,
})
f, err := os.Create(filepath.Join(at, m.version+".zip"))
require.NoError(t, err)
require.NoError(t, modzip.CreateFromDir(f, gomodule.Version{Path: m.path, Version: m.version}, src))
require.NoError(t, f.Close())
versions[at] = append(versions[at], m.version)
}
for at, vs := range versions {
writeFiles(t, at, map[string]string{"list": strings.Join(vs, "\n") + "\n"})
}
}

func mod(path, version string, requires ...string) proxyModule {
gomod := "module " + path + "\n\ngo 1.22\n"
for _, r := range requires {
gomod += "\nrequire " + r + "\n"
}
return proxyModule{path: path, version: version, gomod: gomod}
}

// The go command decides the build list of a workspace. Each case runs it
// on a proxy on the disk, and compares Discover with go list -m all.
func TestDiscoverGivesTheBuildListOfTheGoCommand(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")
}
cases := []struct {
name string
files map[string]string
modules []proxyModule
}{
{
name: "a module of the workspace requires a lower version than another",
files: map[string]string{
"go.work": "go 1.22\n\nuse ./a\nuse ./b\n",
"a/go.mod": "module example.com/a\n\ngo 1.22\n\nrequire (\n\texample.com/x v1.0.0\n\texample.com/z v1.0.0\n)\n",
"b/go.mod": "module example.com/b\n\ngo 1.22\n\nrequire example.com/x v1.2.0\n",
},
modules: []proxyModule{
mod("example.com/x", "v1.0.0"),
mod("example.com/x", "v1.2.0", "example.com/y v1.0.0"),
mod("example.com/y", "v1.0.0", "example.com/z v1.5.0"),
mod("example.com/z", "v1.0.0"),
mod("example.com/z", "v1.5.0"),
},
},
{
name: "a requirement on a module of the workspace at a version",
files: map[string]string{
"go.work": "go 1.22\n\nuse ./a\nuse ./b\n",
"a/go.mod": "module example.com/a\n\ngo 1.22\n\nrequire (\n\texample.com/b v0.1.0\n\texample.com/z v1.0.0\n)\n",
"b/go.mod": "module example.com/b\n\ngo 1.22\n\nrequire example.com/z v1.0.0\n",
},
modules: []proxyModule{
mod("example.com/b", "v0.1.0", "example.com/z v1.5.0"),
mod("example.com/z", "v1.0.0"),
mod("example.com/z", "v1.5.0"),
},
},
{
name: "a module of the workspace below go 1.17",
files: map[string]string{
"go.work": "go 1.22\n\nuse ./a\nuse ./c\n",
"a/go.mod": "module example.com/a\n\ngo 1.22\n\nrequire (\n\texample.com/x v1.0.0\n\texample.com/z v1.0.0\n)\n",
"c/go.mod": "module example.com/c\n\ngo 1.16\n\nrequire example.com/x v1.0.0\n",
},
modules: []proxyModule{
mod("example.com/x", "v1.0.0", "example.com/y v1.0.0"),
mod("example.com/y", "v1.0.0", "example.com/z v1.5.0"),
mod("example.com/z", "v1.0.0"),
mod("example.com/z", "v1.5.0"),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root, proxy, cache := t.TempDir(), t.TempDir(), t.TempDir()
writeFiles(t, root, tc.files)
writeProxy(t, proxy, tc.modules)
work := filepath.Join(root, "go.work")
env := []string{
"GOWORK=" + work, "GOMODCACHE=" + cache, "GOPROXY=file://" + filepath.ToSlash(proxy),
"GOSUMDB=off", "GONOSUMDB=*", "GOFLAGS=-modcacherw",
}
dir := filepath.Join(root, "a")
_, err := goCommand(dir, env, "mod", "download", "all")
require.NoError(t, err)
want := goListBuildList(t, dir, env)
sources, err := Discover(DiscoverOptions{Dir: dir, Work: work, ModuleCache: cache, LocalRoot: root})
require.NoError(t, err)
got := buildListOf("example.com/a", sources)
assert.Equal(t, want, got)
})
}
}
Loading
Loading