From fde561075dfb60f24177b6bd26864c8981eee082 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 18:32:36 +0200 Subject: [PATCH] =?UTF-8?q?mesh-build-go:=20take=20the=20fetch's=20request?= =?UTF-8?q?=20in=20mesh-sdk's=20one=20form=20and=20narrow=20the=20workspac?= =?UTF-8?q?e=20to=20the=20module's=20named=20paths=20(issue=201758,=20ADR?= =?UTF-8?q?=200499=20=C2=A74,=20lead=20decision=20L8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_go_fetch parses its arguments with buildstep.ParseFetchRequest; with dir and paths (from the builder, never from a manifest) the tree is narrowed after the fetch and the merge to those paths, the manifest and the lock files, so a build reads nothing else. The narrowing waits to move into mesh-sdk as buildstep.Narrow. --- .../mesh-build-go/cmd/mesh-build-go/fetch.go | 42 +++++++--- .../mesh-build-go/cmd/mesh-build-go/narrow.go | 83 +++++++++++++++++++ .../mesh-build-go/narrow_integration_test.go | 51 ++++++++++++ .../cmd/mesh-build-go/narrow_test.go | 76 +++++++++++++++++ 4 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/narrow.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/narrow_test.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index c273c230..732db4e5 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -67,6 +67,8 @@ func (m *Module) fetchTool() StepTool { "commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"}, "ref": map[string]any{"type": "string", "description": "refs/pull//head when only a pull request's head carries the commit"}, "base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"}, + "dir": buildstep.FetchRequestProperties()["dir"], + "paths": buildstep.FetchRequestProperties()["paths"], "beside": map[string]any{"type": "array", "description": "optional sibling repositories the tests read, each placed as a plain directory (no symlink) under the workspace's beside/, which build_go_test gives the tests as MESH_CHECK_BESIDE", "items": map[string]any{"type": "object", "properties": map[string]any{ "repository": map[string]any{"type": "string", "description": "/ on the forge"}, @@ -81,19 +83,16 @@ func (m *Module) fetchTool() StepTool { } func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) { - repository, _ := args["repository"].(string) - commit, _ := args["commit"].(string) - ref, _ := args["ref"].(string) - base, _ := args["base"].(string) - if base != "" && !commitOK.MatchString(base) { - return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base) - } - siblings, err := siblingsArg(args["beside"]) + // What the builder and the checker ask, in mesh-sdk's one form (ADR 0499 §4): with the module's directory and its + // named paths, the workspace is narrowed to them (narrow.go). + req, err := buildstep.ParseFetchRequest(args) if err != nil { return nil, err } - if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") { - return nil, fmt.Errorf("%q is not a repository, /", repository) + repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base + siblings, err := siblingsArg(args["beside"]) + if err != nil { + return nil, err } m.CleanWorkspaces(m.now()) id := buildstep.NewID() @@ -115,6 +114,15 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) { if res.Beside, err = m.placeSiblings(ctx, root, siblings); err != nil { return nil, err } + if (base == "" || res.Merge != "") && len(req.Paths) > 0 { + if err := narrow(tree, req); err != nil { + return nil, err + } + // Said beside the tree, so the steps that list the tree's go.mod files leave out those narrowed away. + if err := os.WriteFile(filepath.Join(root, narrowedMark), nil, 0o600); err != nil { + return nil, err + } + } dirs, err := m.goModDirs(ctx, tree) if err != nil { return nil, err @@ -177,6 +185,11 @@ func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) { dirs := []string{} for _, l := range strings.Split(r.Stdout, "\n") { if l = strings.TrimSpace(l); l != "" && !inTestdata(l) { + if narrowed(tree) { + if _, err := os.Stat(filepath.Join(tree, filepath.FromSlash(l))); err != nil { + continue // outside a narrowed workspace's paths (narrow.go): no module of this build + } + } dirs = append(dirs, filepath.ToSlash(filepath.Dir(l))) } } @@ -268,3 +281,12 @@ func firstLine(s string) string { } return "" } + +// narrowedMark is the file beside a workspace's tree saying the fetch narrowed it to a module's named paths. +const narrowedMark = "narrowed" + +// narrowed says whether the fetch narrowed the workspace whose tree this is. +func narrowed(tree string) bool { + _, err := os.Stat(filepath.Join(filepath.Dir(tree), narrowedMark)) + return err == nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/narrow.go b/modules/mesh-build-go/cmd/mesh-build-go/narrow.go new file mode 100644 index 00000000..af4fb38d --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/narrow.go @@ -0,0 +1,83 @@ +package main + +// The narrowed workspace (novox/hq ADR 0499 §4; issue 1758, the lead's decision L8): a build of a module whose every +// artifact names its paths sees only those paths, the module's manifest and its lock files. The paths come from the +// builder's request (buildstep.FetchRequest's Dir and Paths, from the indexer's kept inputs), never from a manifest. +// Everything else of the fetched tree is removed before any run step, so a read outside fails, naming the path. + +import ( + "fmt" + "os" + "path" + "path/filepath" + "sort" + + "git.novox.be/novox/mesh-sdk/go/buildstep" +) + +// narrowKept are the files of the module's directory a narrowed workspace keeps beside its paths: the manifest and the +// lock files of both languages. +var narrowKept = []string{"module.json", "package.json", "package-lock.json", "go.mod", "go.sum"} + +// narrow removes from tree everything but r's paths in its module directory, the manifest and the lock files (and the +// repository's .git). A request naming no paths leaves the tree whole. A named path the tree lacks fails, naming it. +func narrow(tree string, r buildstep.FetchRequest) error { + if len(r.Paths) == 0 { + return nil + } + dir := path.Clean(r.Dir) + keep := map[string]bool{} + for _, p := range r.Paths { + rel := path.Join(dir, path.Clean(p)) + if _, err := os.Lstat(filepath.Join(tree, filepath.FromSlash(rel))); err != nil { + return fmt.Errorf("the path %s the request names is not in the commit: %w", rel, err) + } + keep[rel] = true + } + for _, f := range narrowKept { + keep[path.Join(dir, f)] = true + } + return prune(tree, "", keep) +} + +// prune removes under tree/rel everything that is neither kept nor above or below a kept path. +func prune(tree, rel string, keep map[string]bool) error { + entries, err := os.ReadDir(filepath.Join(tree, filepath.FromSlash(rel))) + if err != nil { + return err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + sort.Strings(names) + for _, name := range names { + p := path.Join(rel, name) + if rel == "" && name == ".git" { + continue + } + switch { + case keep[p]: + continue // a kept path, whole + case holdsKept(p, keep): + if err := prune(tree, p, keep); err != nil { + return err + } + default: + if err := os.RemoveAll(filepath.Join(tree, filepath.FromSlash(p))); err != nil { + return err + } + } + } + return nil +} + +// holdsKept says whether a kept path lies below p. +func holdsKept(p string, keep map[string]bool) bool { + for k := range keep { + if len(k) > len(p) && k[:len(p)+1] == p+"/" { + return true + } + } + return false +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go new file mode 100644 index 00000000..421e1f6b --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go @@ -0,0 +1,51 @@ +//go:build integration + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// ADR 0499 §4 (issue 1758): a fetch asked with the module's directory and its named paths leaves the workspace holding +// only those, the manifest and the lock files; the rest of the commit is gone before any run step; a request naming +// paths without a directory is refused before anything is fetched. +func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) { + f := newForge(t) + _, head := f.repo("novox/narrow", map[string]string{ + "README.md": "the repository\n", + "lib/go.mod": "module fixture/lib\n\ngo 1.25.0\n", + "lib/module.json": `{"module":"lib","version":"1"}`, + "lib/cmd/x/x.go": "package main\n\nfunc main() {}\n", + "lib/docs/a.md": "not named\n", + "other/go.mod": "module fixture/other\n\ngo 1.25.0\n", + "other/o.go": "package other\n", + }) + m, states := f.module(t) + res, st := fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "dir": "lib", "paths": []any{"cmd"}}) + if st.State != delivery.StepPassed { + t.Fatalf("the fetch ended %s: %s", st.State, st.Reason) + } + tree := filepath.Join(m.Work, res.Workspace, "tree") + for _, kept := range []string{"lib/go.mod", "lib/module.json", "lib/cmd/x/x.go"} { + if _, err := os.Stat(filepath.Join(tree, kept)); err != nil { + t.Errorf("%s was narrowed away: %v", kept, err) + } + } + for _, gone := range []string{"README.md", "lib/docs/a.md", "other/o.go"} { + if _, err := os.Stat(filepath.Join(tree, gone)); err == nil { + t.Errorf("%s is still in the workspace", gone) + } + } + if strings.Join(res.Modules, ",") != "lib" { + t.Errorf("the fetch answered the modules %v, want lib alone", res.Modules) + } + _, st = fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "paths": []any{"cmd"}}) + if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "no module directory") { + t.Errorf("paths without a directory: %s %q", st.State, st.Reason) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/narrow_test.go b/modules/mesh-build-go/cmd/mesh-build-go/narrow_test.go new file mode 100644 index 00000000..ba73232c --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/narrow_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buildstep" +) + +func files(t *testing.T, root string) []string { + t.Helper() + var out []string + filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() { + rel, _ := filepath.Rel(root, p) + if !strings.HasPrefix(rel, ".git/") { + out = append(out, filepath.ToSlash(rel)) + } + } + return nil + }) + slices.Sort(out) + return out +} + +func narrowFixture(t *testing.T) string { + t.Helper() + tree := t.TempDir() + for _, f := range []string{".git/HEAD", "README.md", "module.json", "package.json", "package-lock.json", "tsconfig.json", + "src/index.ts", "src/deep/a.ts", "test/a.test.ts", "go/go.mod", "go/x.go", "other/module.json", "srcx/b.ts"} { + p := filepath.Join(tree, filepath.FromSlash(f)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(f), 0o644); err != nil { + t.Fatal(err) + } + } + return tree +} + +// ADR 0499 §4: a request naming paths leaves only those, the manifest and the lock files (and .git); a path the commit +// lacks fails naming it; a request naming none leaves the tree whole. +func TestTheWorkspaceIsNarrowedToTheNamedPathsTheManifestAndTheLocks(t *testing.T) { + tree := narrowFixture(t) + r := buildstep.FetchRequest{Dir: ".", Paths: []string{"src", "test", "tsconfig.json"}} + if err := narrow(tree, r); err != nil { + t.Fatal(err) + } + want := []string{"module.json", "package-lock.json", "package.json", "src/deep/a.ts", "src/index.ts", "test/a.test.ts", "tsconfig.json"} + if got := files(t, tree); !slices.Equal(got, want) { + t.Errorf("kept %v, want %v", got, want) + } + if _, err := os.Stat(filepath.Join(tree, ".git", "HEAD")); err != nil { + t.Errorf("the repository's .git went: %v", err) + } + tree = narrowFixture(t) + if err := narrow(tree, buildstep.FetchRequest{Dir: "go", Paths: []string{"x.go"}}); err != nil { + t.Fatal(err) + } + if got := files(t, tree); !slices.Equal(got, []string{"go/go.mod", "go/x.go"}) { + t.Errorf("a module in a directory kept %v", got) + } + tree = narrowFixture(t) + if err := narrow(tree, buildstep.FetchRequest{Dir: ".", Paths: []string{"lib"}}); err == nil || !strings.Contains(err.Error(), "lib") { + t.Errorf("a path the commit lacks answered %v", err) + } + tree = narrowFixture(t) + before := files(t, tree) + if err := narrow(tree, buildstep.FetchRequest{}); err != nil || !slices.Equal(files(t, tree), before) { + t.Errorf("a request naming no paths changed the tree: %v", err) + } +}