diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins.go b/modules/mesh-build-go/cmd/mesh-build-go/pins.go index 69061e2a..f51f5239 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins.go @@ -203,5 +203,41 @@ func (m *Module) ensureModfile(dir string) (string, error) { if err := os.WriteFile(filepath.Join(pins, "go.sum"), sum, 0o600); err != nil { return "", err } - return modfile, os.WriteFile(modfile, body, 0o600) + return modfile, os.WriteFile(modfile, absoluteReplaces(body, dir), 0o600) +} + +// absoluteReplaces is a go.mod with every replace to a relative directory (./x, ../x) made absolute under dir, the +// directory the go.mod came from. The temporary modfile lives elsewhere and `go mod verify` runs in its directory, so a +// relative target (a package of the module's own repository in a sibling directory) would resolve there to nothing. +func absoluteReplaces(body []byte, dir string) []byte { + lines := strings.Split(string(body), "\n") + inBlock := false + for i, line := range lines { + text := strings.TrimSpace(line) + switch { + case inBlock && text == ")": + inBlock = false + continue + case !inBlock && strings.HasPrefix(text, "replace") && strings.HasSuffix(strings.TrimSpace(strings.TrimPrefix(text, "replace")), "("): + inBlock = true + continue + case !inBlock && !strings.HasPrefix(text, "replace ") && !strings.HasPrefix(text, "replace\t"): + continue + } + before, after, ok := strings.Cut(line, "=>") + if !ok { + continue + } + fields := strings.Fields(after) + if len(fields) == 0 { + continue + } + target := strings.Trim(fields[0], `"`) + if target != "." && target != ".." && !strings.HasPrefix(target, "./") && !strings.HasPrefix(target, "../") { + continue + } + fields[0] = filepath.Join(dir, filepath.FromSlash(target)) + lines[i] = before + "=> " + strings.Join(fields, " ") + } + return []byte(strings.Join(lines, "\n")) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go b/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go index a29e4fac..48829ce5 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -248,3 +249,44 @@ func TestTwoCommitsOfOneRepositoryGetTwoPlacements(t *testing.T) { } } } + +// A relative replace of a go.mod (a package of the module's own repository in a sibling directory, ADR 0593 ยง1) must still +// resolve in the temporary modfile, which `go mod verify` runs in its own directory: every relative target is made absolute +// under the tree it came from. The route-proxy failure of 2026-11-02 13:53 was a ./ replace met there; this takes real go. +func TestARelativeReplaceStillResolvesInTheTemporaryModfile(t *testing.T) { + m := moduleWith(newStates(), "go1.26.0") + m.Work, m.Cache = t.TempDir(), t.TempDir() + tree := filepath.Join(m.Work, "ws1", "tree") + write := func(path, text string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(tree, "modules", "sib", "go.mod"), "module example.com/sib\n\ngo 1.26\n") + write(filepath.Join(tree, "modules", "sib", "sib.go"), "package sib\n") + write(filepath.Join(tree, "modules", "sib", "inner", "go.mod"), "module example.com/inner\n\ngo 1.26\n") + dir := filepath.Join(tree, "modules", "user") + write(filepath.Join(dir, "go.mod"), "module example.com/user\n\ngo 1.26\n\nrequire (\n\texample.com/sib v0.0.0\n\texample.com/inner v0.0.0 // inner\n\texample.com/own v0.0.0\n)\n\n"+ + "replace example.com/sib => ../sib\n\nreplace (\n\texample.com/inner v0.0.0 => ../sib/inner\n\texample.com/own => ./own\n)\n") + write(filepath.Join(dir, "own", "go.mod"), "module example.com/own\n\ngo 1.26\n") + write(filepath.Join(dir, "main.go"), "package main\n\nimport _ \"example.com/sib\"\n\nfunc main() {}\n") + modfile, err := m.ensureModfile(dir) + if err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(modfile) + for _, want := range []string{"=> " + filepath.Join(tree, "modules", "sib"), "=> " + filepath.Join(tree, "modules", "sib", "inner"), "=> " + filepath.Join(dir, "own")} { + if !strings.Contains(string(raw), want) { + t.Errorf("the modfile lacks %q:\n%s", want, raw) + } + } + cmd := exec.Command("go", "-C", filepath.Dir(modfile), "mod", "verify") + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod", "GOPROXY=off", "GOWORK=off", "GOMODCACHE="+t.TempDir()) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go mod verify in the pins directory: %v\n%s", err, out) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go index bad6e1b9..e758fab7 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go @@ -283,6 +283,18 @@ func lockProblem(dir, rel string) (string, error) { if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil { return fmt.Sprintf("%s names packages beyond the SDK and has no package-lock.json (%s): commit one", rel, strings.Join(names, ", ")), nil } + _, pkg, _ := ownDependencies(dir) + for _, k := range []string{"dependencies", "peerDependencies", "optionalDependencies"} { + deps, _ := pkg[k].(map[string]any) + for _, name := range names { + spec, _ := deps[name].(string) + for _, scheme := range []string{"file:", "link:", "portal:"} { + if path, ok := strings.CutPrefix(spec, scheme); ok && (path == "." || path == ".." || strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../")) { + return fmt.Sprintf("%s depends on %s by the relative path %s, which the install in a scratch copy cannot resolve: name the package from the registry", rel, name, spec), nil + } + } + } + } return "", nil } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go b/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go index f993a2b7..ee023a45 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go @@ -168,6 +168,23 @@ func TestAModuleWithoutALockIsRefusedInTheResultNotTheFetch(t *testing.T) { } } +// A dependency on a directory by a relative path (file: or link:, as a go.mod replace is in Go) would resolve to nothing in +// the scratch copy `npm ci` runs in, so the module is refused naming the package and its path, as one without a lock is, +// never installed wrongly (the kind of the 2026-11-02 13:53 route-proxy failure in mesh-build-go). +func TestAModuleDependingOnARelativeDirectoryIsRefusedNamingIt(t *testing.T) { + files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6","sibling":"file:../sibling"}}`) + files["modules/m/package-lock.json"] = `{"lockfileVersion":3}` + m, f := fetchMachine(t, files, nil) + out, err := m.fetch(context.Background(), fetchArgs) + if err != nil { + t.Fatalf("the fetch failed: %v", err) + } + r := out.(FetchResult) + if len(npmCalls(f)) != 0 || len(r.Refused) != 1 || !strings.Contains(r.Refused[0].Reason, "sibling") || !strings.Contains(r.Refused[0].Reason, "file:../sibling") { + t.Errorf("refused = %+v after %d npm calls, want the dependency named and nothing installed", r.Refused, len(npmCalls(f))) + } +} + // 1489 (review 4): a bundle depending on another @novox package is installed, the mesh's registry given to npm ci // through --userconfig, as the toolchain's install gives it. func TestAnotherNovoxPackageIsInstalledFromTheRegistryThroughAUserconfig(t *testing.T) {