diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go index 6dc5381..ed8ff96 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -3,6 +3,7 @@ package main import ( "context" "sort" + "strings" "sync" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -31,7 +32,7 @@ type judgeDeps struct { func newJudge(d judgeDeps) check.Judge { return &check.Examiner{Steps: d.runner, Toolchains: d.tools, Presence: d.presence, Health: d.have, Places: d.places, - Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Forge: d.forge, Runs: d.repos, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Logf: d.logf} + Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Forge: d.forge, Runs: d.repos, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Logf: d.logf} } // manifestKV reads a manifest by its digest. @@ -47,10 +48,13 @@ type repoModules struct { logf func(format string, args ...any) mu sync.Mutex - langs map[string]map[string]bool // by manifest digest: the languages of its artifacts - repoOf map[string]string // by module: its repository - commit map[string]string // by module: the commit its record was computed at (the commit the mesh runs) - typed map[string]string // by module: its manifest digest + langs map[string]map[string]bool // by manifest digest: the languages of its artifacts + tsPkgs map[string][]string // by manifest digest: the names of its TypeScript package artifacts + path map[string]string // by module: its directory in its repository + named map[string]map[string][]string // by module: each artifact's named paths, as the indexer keeps them + repoOf map[string]string // by module: its repository + commit map[string]string // by module: the commit its record was computed at (the commit the mesh runs) + typed map[string]string // by module: its manifest digest } // Put takes a change of the indexer's modules bucket in: a module's repository and the languages its manifest names. @@ -58,11 +62,14 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted r.mu.Lock() if r.repoOf == nil { r.repoOf, r.typed, r.langs, r.commit = map[string]string{}, map[string]string{}, map[string]map[string]bool{}, map[string]string{} + r.tsPkgs, r.path, r.named = map[string][]string{}, map[string]string{}, map[string]map[string][]string{} } if deleted { delete(r.repoOf, key) delete(r.typed, key) delete(r.commit, key) + delete(r.path, key) + delete(r.named, key) r.mu.Unlock() return } @@ -75,12 +82,15 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted r.repoOf[key] = m.Repository r.typed[key] = m.Manifest r.commit[key] = m.Commit + r.path[key] = m.Path + r.named[key] = m.ArtifactPaths _, known := r.langs[m.Manifest] r.mu.Unlock() if m.Manifest == "" || known { return } set := map[string]bool{} + var pkgs []string if mk, err := delivery.ManifestsKey(m.Manifest); err == nil { if raw, _, found, err := r.manifests.Get(ctx, mk); err == nil && found { if kept, err := delivery.DecodeManifest(raw); err == nil { @@ -89,6 +99,9 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted if a.Language != "" { set[a.Language] = true } + if a.Kind == "package" && a.Language == "typescript" { + pkgs = append(pkgs, a.Name) + } } } else if err != nil && r.logf != nil { r.logf("the manifest %s cannot be read, so its module counts as no TypeScript: %v", m.Manifest, err) @@ -98,9 +111,37 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted } r.mu.Lock() r.langs[m.Manifest] = set + r.tsPkgs[m.Manifest] = pkgs r.mu.Unlock() } +// PackagePaths are the paths, relative to the repository, that the TypeScript package artifacts of a repository's modules +// are built from, sorted: the named paths the indexer keeps in each module's value (delivery.Module.ArtifactPaths; only the +// indexer reads them from a manifest, the lead's decision L8), under the module's directory. None for a module whose value +// holds no paths. +func (r *repoModules) PackagePaths(repository string) []string { + r.mu.Lock() + defer r.mu.Unlock() + var out []string + for module, repo := range r.repoOf { + if repo != repository { + continue + } + dir := strings.Trim(r.path[module], "/") + for _, name := range r.tsPkgs[r.typed[module]] { + for _, p := range r.named[module][name] { + p = strings.Trim(strings.TrimPrefix(p, "./"), "/") + if dir != "" && dir != delivery.RootPath { + p = dir + "/" + p + } + out = append(out, p) + } + } + } + sort.Strings(out) + return out +} + // BuildModules are the build modules that judge a repository, sorted. func (r *repoModules) BuildModules(repository string) []string { out := []string{buildrun.GoBuildModule} diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go index 28329bf..8afa3c1 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge_test.go @@ -73,3 +73,40 @@ func TestARepositoryWithATypeScriptModuleIsJudgedByTheNodeBuildModuleToo(t *test t.Errorf("a repository with no module: %v, want the Go build module", got) } } + +// sdkManifest is mesh-sdk's own manifest (its module.json at feat/r4e-sdk): one TypeScript package naming its paths. +const sdkManifest = `{"module":"mesh-sdk","version":"1","slug":"sdk","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript",` + + `"paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]},"resources":[]}` + +func TestTheTypeScriptPackagesPathsComeFromTheIndexersModulesNotTheManifest(t *testing.T) { // ADR 0500 §5 with lead decision L8 + store := memManifests{} + data, err := delivery.EncodeManifest(delivery.Manifest{Module: "mesh-sdk", JSON: sdkManifest}) + if err != nil { + t.Fatal(err) + } + store[delivery.ManifestDigest([]byte(sdkManifest))] = data + r := &repoModules{manifests: store} + put := func(path string, paths map[string][]string) { + v, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), FP: "src1:" + strings.Repeat("a", 64), + Manifest: delivery.ManifestDigest([]byte(sdkManifest)), Path: path, ArtifactPaths: paths}) + if err != nil { + t.Fatal(err) + } + r.Put(context.Background(), "mesh-sdk", v, false) + } + put(".", map[string][]string{"lib": {"src", "package.json"}}) // the indexer's word, not the manifest's five paths + if got := strings.Join(r.PackagePaths("novox/mesh-sdk"), ","); got != "package.json,src" { + t.Errorf("paths = %q, want the indexer's, at the repository's root", got) + } + put("ts", map[string][]string{"lib": {"src"}}) + if got := strings.Join(r.PackagePaths("novox/mesh-sdk"), ","); got != "ts/src" { + t.Errorf("paths = %q, want them under the module's directory", got) + } + put(".", nil) // a value written before the indexer kept paths: nothing to serve by + if got := r.PackagePaths("novox/mesh-sdk"); len(got) != 0 { + t.Errorf("paths = %v, want none", got) + } + if got := r.PackagePaths("novox/mesh-delivery"); len(got) != 0 { + t.Errorf("another repository: %v", got) + } +} diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index 9c911a8..8f008d1 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-checker go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022160024-9cb4d59093a7 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-checker/go.sum b/modules/mesh-delivery-checker/go.sum index 3515fb8..bb52af2 100644 --- a/modules/mesh-delivery-checker/go.sum +++ b/modules/mesh-delivery-checker/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022160024-9cb4d59093a7 h1:wK9O1CgNk7mo3IdJqW/CCx/hebg+1F0rkvW8CvJfnRM= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022160024-9cb4d59093a7/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index 735eea5..1864f55 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sort" "strings" "sync" @@ -649,3 +650,76 @@ func TestADigestThatEchoesOtherSiblingsKeepsNothing(t *testing.T) { // 1287 with t.Errorf("kept %v under a digest that does not cover every sibling", keys) } } + +type pkgPaths map[string][]string + +func (p pkgPaths) PackagePaths(repository string) []string { return p[repository] } + +// groupPackageBench is a group of a TypeScript member (mesh-delivery) and the mesh-sdk member, whose merge changes sdkChanged. +func groupPackageBench(t *testing.T, sdkChanged []string) *bench { + b := newBench(t) + b.ex.Modules = langs{repoA: {buildrun.GoBuildModule, buildrun.NodeBuildModule}, repoSDK: {buildrun.GoBuildModule, buildrun.NodeBuildModule}} + b.ex.Packages = pkgPaths{repoSDK: {"src", "test", "package.json", "package-lock.json", "tsconfig.json"}} + fetch := func(c stepCall) (any, error) { + f := b.fetch + if c.args["repository"] == repoSDK { + f.Changed = sdkChanged + } + return f, nil + } + b.steps.handlers["build_go_fetch"], b.steps.handlers["build_node_fetch"] = fetch, fetch + return b +} + +func nodeFetchOf(b *bench, repo string) stepCall { + for _, c := range b.steps.tool("fetch") { + if c.module == buildrun.NodeBuildModule && c.args["repository"] == repo { + return c + } + } + return stepCall{} +} + +func TestATypeScriptMemberTakesTheGroupsPackageWhenTheSDKMemberChangesItsPaths(t *testing.T) { // ADR 0500 §5, C11b + b := groupPackageBench(t, []string{"go/delivery/x.go", "src/state.ts"}) + b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))) + want := map[string]any{"repository": repoSDK, "commit": head2, "ref": "refs/pull/12/head"} + if got := nodeFetchOf(b, repoA).args["package"]; !reflect.DeepEqual(got, want) { + t.Errorf("mesh-delivery's Node fetch was given the package %v, want %v", got, want) + } + if got := nodeFetchOf(b, repoSDK).args["package"]; got != nil { + t.Errorf("the mesh-sdk member's own fetch was given %v: it builds its package itself", got) + } +} + +func TestATypeScriptMemberResolvesTheRegistryWhenTheSDKMemberLeavesItsPaths(t *testing.T) { // ADR 0500 §5: the manifest alone, or Go alone + for _, changed := range [][]string{{"go/delivery/x.go"}, {"module.json"}, {"srcx/a.ts"}} { + b := groupPackageBench(t, changed) + b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))) + if got := nodeFetchOf(b, repoA).args["package"]; got != nil { + t.Errorf("%v: the Node fetch was given %v, want the registry", changed, got) + } + } + // A lone TypeScript pull request: no group package either. + b := groupPackageBench(t, []string{"src/state.ts"}) + b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) + if got := nodeFetchOf(b, repoA).args["package"]; got != nil { + t.Errorf("a lone pull request's Node fetch was given %v", got) + } +} + +func TestNoStepOfACheckCarriesPublish(t *testing.T) { // ADR 0500 §4, 1309 (the checker's half): only the builder's trunk build publishes + b := groupPackageBench(t, []string{"src/state.ts"}) + b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))) + if len(b.steps.calls) == 0 { + t.Fatal("no step ran") + } + for _, c := range b.steps.calls { + if _, ok := c.args["publish"]; ok { + t.Errorf("%s was given publish: %v", c.tool, c.args) + } + if strings.HasSuffix(c.tool, "_build") || strings.HasSuffix(c.tool, "_pack") { + t.Errorf("the checker asked %s: a check builds no artifact of its own", c.tool) + } + } +} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index a241d56..4ada56d 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -69,6 +69,9 @@ type Examiner struct { Builds BuildStates // Announce says a preview was written under the key (EventPreviewWritten); nil says nothing. Announce Announcer + // Packages names, of a repository, the paths (repository-relative) its TypeScript package artifacts are built from, from + // the indexer's modules (delivery.Module.ArtifactPaths, lead decision L8); nil serves no group package. + Packages interface{ PackagePaths(repository string) []string } // Trunks are the trunk heads of every repository, for the siblings a repository's tests read. Trunks interface{ Heads() map[string]string } Now func() time.Time @@ -269,7 +272,20 @@ func (st *state) choose(bm string) (node, toolchain string, err error) { func (st *state) fetchAll(ctx context.Context) *delivery.Verdict { began := st.j.now() defer func() { st.clone = st.j.now().Sub(began) }() - for _, p := range st.c.Pulls { + // The mesh-sdk member first: whether its merge changes the TypeScript package's paths decides whether the other members' + // Node fetches take the group's package (ADR 0500 §5). + pulls := slices.Clone(st.c.Pulls) + slices.SortStableFunc(pulls, func(a, b Pull) int { + if (a.Repository == SDKRepository) != (b.Repository == SDKRepository) { + if a.Repository == SDKRepository { + return -1 + } + return 1 + } + return 0 + }) + var groupPackage map[string]any + for _, p := range pulls { var merged []Fetched for _, bm := range st.j.Modules.BuildModules(p.Repository) { node, tc, err := st.choose(bm) @@ -280,6 +296,9 @@ func (st *state) fetchAll(ctx context.Context) *delivery.Verdict { st.nodes[node] = true args := map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number), "base": p.Trunk} t.fetchArgs = args + if bm == buildrun.NodeBuildModule && groupPackage != nil && p.Repository != SDKRepository { + args["package"] = groupPackage + } if bm == buildrun.GoBuildModule { beside, err := st.j.besideFor(ctx, st.c, p) if err != nil { @@ -311,10 +330,33 @@ func (st *state) fetchAll(ctx context.Context) *delivery.Verdict { if st.c.Try == nil { // a try owes the indexer no preview: no pull request holds its head st.j.writePreview(ctx, p, merged) } + if p.Repository == SDKRepository && len(st.c.Pulls) > 1 && st.j.changesPackage(p, merged) { + groupPackage = map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number)} + } } return nil } +// changesPackage says whether the merge of a pull request changes a file inside the named paths of one of its repository's +// TypeScript packages: a change of the manifest alone, or of files outside those paths, does not (ADR 0500 §5). +func (j *Examiner) changesPackage(p Pull, merged []Fetched) bool { + if j.Packages == nil { + return false + } + paths := j.Packages.PackagePaths(p.Repository) + for _, f := range merged { + for _, changed := range f.Changed { + for _, named := range paths { + named = strings.TrimSuffix(strings.TrimPrefix(named, "./"), "/") + if changed == named || strings.HasPrefix(changed, named+"/") { + return true + } + } + } + } + return false +} + // isTestFile says whether a repository-relative path is a test file of the languages the checker judges: Go's _test.go and // the TypeScript module's .test. and .spec. files and test directories. func isTestFile(p string) bool {