diff --git a/modules/mesh-build-go/cmd/mesh-build-go/compose_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/compose_integration_test.go index 489fd027a..52fd47c0b 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/compose_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/compose_integration_test.go @@ -26,8 +26,9 @@ import ( // nodeEngineTree is the node-engine's repository as the checker places it beside this check (MESH_CHECK_BESIDE, the // repository mesh-host), and with it every Go module of the other repositories placed there (mesh-sdk, mesh-contracts, -// mesh-delivery, mesh-networking: the node-engine's composer requires them at the zero version, so they resolve from the -// workspace as the group lays them, wherever the composer lives). The test +// mesh-delivery, mesh-networking) and of the catalogue under check, as candidates: the workspace lays those the +// node-engine's composer requires at the zero version, transitively (workspaceOf), so they resolve as the group lays +// them, wherever the composer lives. The test // builds the node-engine's real composer command from that tree; it does not stand one in. With no beside directory it // fails, and with the node-engine beside but no composer command in it it fails: a composer test that could not build its // composer proves nothing. With a beside directory that holds no node-engine at all it skips aloud (NOT JUDGED), since a @@ -51,6 +52,7 @@ func nodeEngineTree(t *testing.T) (tree string, modules []string) { if err != nil { t.Fatal(err) } + var roots []string for _, e := range entries { if e.Name() == "mesh-host" || !dirExists(filepath.Join(beside, e.Name())) { continue @@ -59,6 +61,14 @@ func nodeEngineTree(t *testing.T) (tree string, modules []string) { if err != nil { t.Fatal(err) } + roots = append(roots, root) + } + // The catalogue under check is never beside itself; its modules are found here, after the group's others. + catalogue, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) + if err != nil { + t.Fatal(err) + } + for _, root := range append(roots, catalogue) { filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil @@ -172,7 +182,7 @@ func TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus(t *testing.T) { } copyHostTree(t, hostTree, tree) if len(beside) > 0 { // the group's other repositories, laid beside the node-engine's tree as one workspace - work, err := workspaceOf(append([]string{tree}, beside...)) + work, err := workspaceOf(tree, beside) if err != nil { return nil, err } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go b/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go index 91f71b700..17d4c3b72 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go @@ -12,39 +12,77 @@ import ( // zeroVersion is the version a mesh package is required at in every go.mod of the mesh (ADR 0573 §2, §4). const zeroVersion = "v0.0.0-00010101000000-000000000000" -// workspaceOf is the go.work of the modules in dirs, as a test lays the group's repositories beside a tree: each used, and -// each also replaced at the zero version by its own directory. Go (from 1.27) looks the zero version of a required -// module up even where the module is a member of the workspace, once a build loads the module graph (any import outside -// the workspace); a versioned replace is the one form it takes without a lookup, and an unversioned one is refused for a -// member of the workspace (issue 1921: the composer's build fetched mesh-contracts from the network). -func workspaceOf(dirs []string) (string, error) { - var use, replace strings.Builder - seen := map[string]bool{} - for _, d := range dirs { - raw, err := os.ReadFile(filepath.Join(d, "go.mod")) +// workspaceOf is the go.work of the module in first and of every module it requires at the zero version, transitively, +// each found among the modules in candidates by its module path: each used, and each also replaced at the zero version by +// its own directory. Go (from 1.27) looks the zero version of a required module up even where the module is a member of +// the workspace, once a build loads the module graph (any import outside the workspace); a versioned replace is the one +// form it takes without a lookup, and an unversioned one is refused for a member of the workspace (issue 1921: the +// composer's build fetched mesh-contracts from the network). Only what first needs is laid (issue 1921, the group check +// of 8a80e8e86858): a module laid beside that requires one no tree holds (mesh-grants requiring a catalogue module, the +// catalogue being the repository under check) made Go fetch that module's zero version and fail, though the composer +// needs neither. A requirement at the zero version no candidate holds is an error naming it and who requires it. +func workspaceOf(first string, candidates []string) (string, error) { + paths := map[string]string{} + for _, d := range candidates { + path, _, err := goModOf(d) if err != nil { return "", err } - path := "" - for _, line := range strings.Split(string(raw), "\n") { - if f := strings.Fields(line); len(f) == 2 && f[0] == "module" { - path = strings.Trim(f[1], `"`) - break - } + if _, seen := paths[path]; !seen { // the same module laid twice: the first is kept + paths[path] = d } - if path == "" { - return "", fmt.Errorf("%s/go.mod names no module", d) + } + var use, replace strings.Builder + laid := map[string]bool{} + queue := []string{first} + for len(queue) > 0 { + d := queue[0] + queue = queue[1:] + path, needs, err := goModOf(d) + if err != nil { + return "", err } - if seen[path] { - continue // the same module laid twice: go refuses a workspace that uses it twice + if laid[path] { + continue } - seen[path] = true + laid[path] = true fmt.Fprintf(&use, "\t%s\n", d) fmt.Fprintf(&replace, "replace %s %s => %s\n", path, zeroVersion, d) + for _, need := range needs { + if laid[need] { + continue + } + dir, ok := paths[need] + if !ok { + return "", fmt.Errorf("%s (%s) requires %s at the zero version, and no tree laid beside holds it", path, d, need) + } + queue = append(queue, dir) + } } return "go 1.26.0\n\nuse (\n" + use.String() + ")\n\n" + replace.String(), nil } +// goModOf is the module path of the go.mod in d and the modules it requires at the zero version. +func goModOf(d string) (path string, needs []string, err error) { + raw, err := os.ReadFile(filepath.Join(d, "go.mod")) + if err != nil { + return "", nil, err + } + for _, line := range strings.Split(string(raw), "\n") { + f := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "require ")) + switch { + case len(f) == 2 && f[0] == "module" && path == "": + path = strings.Trim(f[1], `"`) + case len(f) >= 2 && f[1] == zeroVersion: + needs = append(needs, strings.Trim(f[0], `"`)) + } + } + if path == "" { + return "", nil, fmt.Errorf("%s/go.mod names no module", d) + } + return path, needs, nil +} + // Issue 1921 (the fourth group try): a workspace whose members require each other at the zero version loads its module // graph with no lookup, so with nothing fetched (GOPROXY=off), as a composer built beside the group's heads does. func TestAWorkspaceOfTheGroupResolvesTheZeroVersionWithNoLookup(t *testing.T) { @@ -61,10 +99,15 @@ func TestAWorkspaceOfTheGroupResolvesTheZeroVersionWithNoLookup(t *testing.T) { } a := mod("a", "module git.novox.be/novox/mesh-host\n\ngo 1.26.0\n\nrequire git.novox.be/novox/mesh-contracts/go/x "+zeroVersion+"\n") b := mod("b", "module git.novox.be/novox/mesh-contracts/go/x\n\ngo 1.26.0\n") - work, err := workspaceOf([]string{a, b, b}) + // The group check of 8a80e8e86858: a module beside requiring one no tree holds is not laid, so nothing looks it up. + c := mod("c", "module git.novox.be/novox/mesh-delivery/modules/g\n\ngo 1.26.0\n\nrequire (\n\tgit.novox.be/novox/mesh-catalog/modules/d "+zeroVersion+"\n)\n") + work, err := workspaceOf(a, []string{b, c, b}) if err != nil { t.Fatal(err) } + if strings.Contains(work, c) { + t.Errorf("the workspace lays %s, which the composer does not require:\n%s", c, work) + } if err := os.WriteFile(filepath.Join(root, "go.work"), []byte(work), 0o600); err != nil { t.Fatal(err) } @@ -74,4 +117,8 @@ func TestAWorkspaceOfTheGroupResolvesTheZeroVersionWithNoLookup(t *testing.T) { if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), "git.novox.be/novox/mesh-contracts/go/x") { t.Fatalf("the workspace's module graph does not load with nothing fetched: %v\n%s\n%s", err, out, work) } + // A requirement at the zero version no tree holds is said before Go looks it up, naming it. + if _, err := workspaceOf(c, []string{a, b}); err == nil || !strings.Contains(err.Error(), "mesh-catalog/modules/d") { + t.Errorf("a requirement no tree holds: %v, want an error naming it", err) + } }