diff --git a/modules/mesh-delivery-builder/buildrun/asks.go b/modules/mesh-delivery-builder/buildrun/asks.go index 1af73b84..eb0b219f 100644 --- a/modules/mesh-delivery-builder/buildrun/asks.go +++ b/modules/mesh-delivery-builder/buildrun/asks.go @@ -149,8 +149,13 @@ type stepSeenKey struct{} // WithStepSeen is ctx carrying seen, which Asker.Wait hands every value the ask's step key takes while it waits: the node // that took it, each phase it says while running (buildstep.Step.Phase), and its end. A try names its asks' progress so -// (issue 1890). seen runs in the waiting goroutine and must not block. +// (issue 1890). seen runs in the waiting goroutine and must not block. A seen ctx already carries is kept: both are handed +// every value, the earlier first (a check's begin bound watches the steps of a try that names them). func WithStepSeen(ctx context.Context, seen func(buildstep.Step)) context.Context { + if before := stepSeenOf(ctx); before != nil { + later := seen + seen = func(s buildstep.Step) { before(s); later(s) } + } return context.WithValue(ctx, stepSeenKey{}, seen) } diff --git a/modules/mesh-delivery-builder/buildrun/asks_test.go b/modules/mesh-delivery-builder/buildrun/asks_test.go index 357ade87..50c3e4cf 100644 --- a/modules/mesh-delivery-builder/buildrun/asks_test.go +++ b/modules/mesh-delivery-builder/buildrun/asks_test.go @@ -26,10 +26,13 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { return buildstep.Step{ID: a.ID, Node: "ace", State: buildstep.StepPassed, Result: []byte(`{"artifacts":[]}`)} }} k := Asker{Publisher: f, Waiter: f} - var seen []buildstep.Step // issue 1890: a waiter that asks to see each value of the step sees it - step, err := k.Ask(WithStepSeen(context.Background(), func(s buildstep.Step) { seen = append(seen, s) }), goAsk("mvl0frv18d1957")) - if err != nil || step.Node != "ace" || len(f.Asks()) != 1 || len(seen) != 1 || seen[0].Node != "ace" { - t.Fatalf("step %+v, %v, asks %d, seen %+v", step, err, len(f.Asks()), seen) + var seen, also []buildstep.Step // issue 1890: a waiter that asks to see each value of the step sees it + // Issue 1921: a second seen on the same context (a check's begin bound inside a try) is added, never in place of the first. + both := WithStepSeen(WithStepSeen(context.Background(), func(s buildstep.Step) { seen = append(seen, s) }), + func(s buildstep.Step) { also = append(also, s) }) + step, err := k.Ask(both, goAsk("mvl0frv18d1957")) + if err != nil || step.Node != "ace" || len(f.Asks()) != 1 || len(seen) != 1 || seen[0].Node != "ace" || len(also) != 1 { + t.Fatalf("step %+v, %v, asks %d, seen %+v and %+v", step, err, len(f.Asks()), seen, also) } f.Answer = func(a buildstep.StepAsk) buildstep.Step { return buildstep.Step{ID: a.ID, Node: "shanks", State: buildstep.StepRefused, Reason: "go 1.27 installed"} diff --git a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go new file mode 100644 index 00000000..facd8559 --- /dev/null +++ b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go @@ -0,0 +1,52 @@ +// Command mesh-checking-program is the checker's checking program (novox/hq ADR 0644, ADR 0656; design 47's bootstrap +// delivery): built from a check's heads and run in the build seat's sandbox through the seat node-build-go's ask run, +// with no network and no bus account, it reads the check's facts from the file MESH_RUN_INPUT names and answers the +// verdict the judging rules give them, as one JSON line on its standard output (rules.Answer). +// +// mesh-checking-program -verdict +// mesh-checking-program -seats +// +// Asked -seats, it answers the seat list of the heads laid in the directory MESH_RUN_HEADS names (rules.SeatsAnswer). +// It refuses facts of a version it does not read, naming it, with exit 2. +package main + +import ( + "fmt" + "io" + "io/fs" + "os" + "slices" + + "mesh-delivery-checker/rules" + + "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +func main() { + os.Exit(run(os.Args[1:], os.Getenv, os.Stdout, os.Stderr)) +} + +// run is the command over its arguments, environment and outputs, answering its exit. +func run(args []string, getenv func(string) string, stdout, stderr io.Writer) int { + var heads fs.FS + if dir := getenv(buildstep.RunHeadsEnv); dir != "" { + heads = os.DirFS(dir) + } + input := func() ([]byte, error) { + file := getenv(buildstep.RunInputEnv) + if file == "" { + return nil, fmt.Errorf("%s names no file", buildstep.RunInputEnv) + } + return os.ReadFile(file) + } + line, err := rules.Program(args, input, heads) + if err != nil { + if heads == nil && slices.Contains(args, "-seats") { + err = fmt.Errorf("no heads: %s names no directory", buildstep.RunHeadsEnv) + } + fmt.Fprintf(stderr, "mesh-checking-program: %v\n", err) + return 2 + } + fmt.Fprintf(stdout, "%s\n", line) + return 0 +} diff --git a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go new file mode 100644 index 00000000..ff6aeec3 --- /dev/null +++ b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "mesh-delivery-checker/rules" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// 1921 (B0, D2): the checking program answers, on its last line, the verdict the rules give the facts in its input, and +// refuses facts of a version it does not read, an input it is not given, and a question it is not asked, naming each. +// 1921 (B0, D3): asked -seats, it answers the seat list of the heads laid for it: a definition a head adds, resolved with +// the parts it includes; what it left out, and why; and an empty list for heads with no definition. +func TestTheCheckingProgramAnswersTheVerdictOfItsFacts(t *testing.T) { + failing, _ := json.Marshal(rules.Tested{Level: buildstep.LevelUnit, Tests: 3, Failed: []rules.Failure{{Package: "x/a", Test: "TestA", Cause: "a_test.go:9: boom"}}}) + facts := rules.Facts{V: rules.FactsVersion, Group: "feat/x", Heads: "0123456789ab", Members: []rules.Member{{Repository: "novox/x", Number: 3, Head: "h"}}, + Steps: []rules.Step{ + {Repository: "novox/x", Number: 3, Module: "mesh-build-go", Node: "laptop", Level: &buildstep.LevelAnswer{Level: buildstep.LevelUnit, Tests: []json.RawMessage{failing}}}, + {Repository: "novox/x", Number: 3, Failed: &rules.FailedFirst{Machine: "laptop", Words: "novox/x#3 has files in conflict: go.mod"}}, + }} + dir := t.TempDir() + write := func(name string, v any) string { + data, _ := json.Marshal(v) + p := filepath.Join(dir, name) + if err := os.WriteFile(p, data, 0o600); err != nil { + t.Fatal(err) + } + return p + } + v1 := write("v1.json", facts) + later := facts + later.V = rules.FactsVersion + 1 + v2 := write("v2.json", later) + // The heads as the build node lays them, each at its repository's path: a contracts module of its own defining a seat + // with a part, and the mesh's own contracts module defining a seat of a domain that is not its (ADR 0636 §5). + lay := func(name string, files map[string]string) string { + root := filepath.Join(dir, name) + for p, body := range files { + if err := os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, p), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + return root + } + contracts := func(module string) string { + return `{"module": "` + module + `", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}` + } + heads := lay("heads", map[string]string{ + "novox/x/x-contracts/module.json": contracts("x-contracts"), + "novox/x/x-contracts/seats/node-build-x/seat.json": `{"format":1,"seat":"node-build-x","scope":"node","grain":"whole","domain":"Delivery","decision":"ADR 1","description":"kept","includes":["build"],"verbs":[{"name":"own","input":{"type":"object"},"answer":{"type":"object"}}]}`, + "novox/x/x-contracts/parts/build.json": `{"format":1,"part":"build","verbs":[{"name":"current","input":{"type":"object"},"answer":{"type":"object"}}],"events":[{"name":"ended","body":{"type":"object"}}]}`, + "novox/x/contracts/module.json": contracts("mesh-contracts"), + "novox/x/contracts/seats/mesh-catalog/seat.json": `{"format":1,"seat":"mesh-catalog","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1"}`, + }) + broken := lay("broken", map[string]string{ + "novox/x/x-contracts/module.json": contracts("x-contracts"), + "novox/x/x-contracts/seats/broken/seat.json": `{"format":1,"seat":"broken","scope":"nowhere"}`, + }) + none := filepath.Join(dir, "no-heads") + if err := os.MkdirAll(filepath.Join(none, "novox/x"), 0o755); err != nil { + t.Fatal(err) + } + for name, c := range map[string]struct { + args []string + input, heads string + exit int + said string + }{ + "the seats": {args: []string{"-seats"}, input: v1, heads: heads}, + "no seats": {args: []string{"-seats"}, input: v1, heads: none}, + "a seat unparsed": {args: []string{"-seats"}, input: v1, heads: broken, exit: 2, said: "the seat definition of broken (module x-contracts) at the head of novox/x is not taken"}, + "no heads laid": {args: []string{"-seats"}, exit: 2, said: buildstep.RunHeadsEnv + " names no directory"}, + "the facts": {args: []string{"-verdict"}, input: v1}, + "a later version": {args: []string{"-verdict"}, input: v2, exit: 2, said: "facts of version 2, this checking program reads version 1"}, + "no input": {args: []string{"-verdict"}, exit: 2, said: buildstep.RunInputEnv + " names no file"}, + "nothing asked": {input: v1, exit: 2, said: "nothing asked"}, + "an unknown asking": {args: []string{"-judge"}, input: v1, exit: 2, said: "-judge"}, + } { + var out, errs bytes.Buffer + exit := run(c.args, func(k string) string { + switch k { + case buildstep.RunInputEnv: + return c.input + case buildstep.RunHeadsEnv: + return c.heads + } + return "" + }, &out, &errs) + if exit != c.exit || !strings.Contains(errs.String(), c.said) { + t.Errorf("%s: exit %d, said %q; want %d and %q", name, exit, errs.String(), c.exit, c.said) + continue + } + if c.exit != 0 { + continue + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if c.heads != "" { + var sa rules.SeatsAnswer + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &sa); err != nil || sa.V != rules.SeatsVersion || sa.Seats == nil { + t.Fatalf("%s: the last line %q: %v", name, lines[len(lines)-1], err) + } + if c.heads == none { + if len(sa.Seats) != 0 || len(sa.Left) != 0 { + t.Errorf("%s: %+v, want an empty list", name, sa) + } + continue + } + var d struct { + Seat string `json:"seat"` + Verbs []struct { + Name string `json:"name"` + } `json:"verbs"` + } + if len(sa.Seats) == 1 { + _ = json.Unmarshal(sa.Seats[0], &d) + } + if d.Seat != "node-build-x" || len(d.Verbs) != 2 || len(sa.Left) != 1 || !strings.Contains(sa.Left[0].Why, "whose seats mesh-delivery-contracts defines") { + t.Errorf("%s: seats %s, left %+v; want node-build-x with its part's verb, and mesh-catalog left out", name, sa.Seats, sa.Left) + } + continue + } + var a rules.Answer + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &a); err != nil || a.V != rules.AnswerVersion { + t.Fatalf("%s: the last line %q: %v", name, lines[len(lines)-1], err) + } + if want := rules.Judge(facts); !reflect.DeepEqual(a.Verdict, want) || a.Verdict.State != meshdeliverycheckerclient.VerdictFail || len(a.Verdict.Failures) != 2 || + a.Verdict.Failures[1].Words != "novox/x#3 has files in conflict: go.mod" || a.Verdict.Machines[0] != "laptop" || a.Verdict.Levels[buildstep.LevelUnit].Run != 3 { + t.Errorf("%s: the verdict %+v, want %+v", name, a.Verdict, want) + } + } // Review round 1 of milestone 47/B0: the module graph's cycle rule is the program's (ADR 0603 §2, ADR 0656 §5), over the + // edges the running checker gathers. A cycle the change makes fails; one the trunk has among the same modules is noted. + web, mail := rules.Edge{From: "web", To: "mail", Kind: rules.EdgeAwaits}, rules.Edge{From: "mail", To: "web", Kind: rules.EdgeAwaits} + only := func(e rules.Edge, where string) rules.Edge { e.Only = where; return e } + for _, row := range []struct { + name string + graph rules.Graph + fails string + note string + }{ + {name: "no cycle", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{web}}, note: "has no cycle"}, + {name: "a cycle the change makes", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, only(web, rules.EdgeChange)}}, fails: "has the cycle mail -> web -> mail"}, + {name: "a cycle the trunk has", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, web}}, note: "which the trunk already has"}, + {name: "the trunk unread", graph: rules.Graph{Edges: []rules.Edge{mail, web}}, fails: "has the cycle mail -> web -> mail"}, + {name: "a base on itself", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{only(rules.Edge{From: "web", To: "web", Kind: rules.EdgeBase}, rules.EdgeChange)}}, fails: "has the cycle web -> web"}, + {name: "an edge only the trunk has", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, only(web, rules.EdgeTrunk)}}, note: "has no cycle"}, + } { + g := row.graph + a := rules.Judged(rules.Facts{V: rules.FactsVersion, Steps: []rules.Step{{Graph: &g}}}) + if row.fails != "" && (a.Verdict.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(a.Verdict.Failed.Words, row.fails)) { + t.Errorf("%s: verdict %+v, want a fail saying %q", row.name, a.Verdict, row.fails) + } + if row.note != "" && (a.Verdict.State != meshdeliverycheckerclient.VerdictPass || !strings.Contains(strings.Join(a.Notes, "\n"), row.note)) { + t.Errorf("%s: verdict %s, notes %q, want a pass noting %q", row.name, a.Verdict.State, a.Notes, row.note) + } + } +} diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go index f89671b7..f7ce0dca 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go @@ -15,6 +15,7 @@ import ( "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/views" @@ -232,11 +233,25 @@ func SeatTryTools(tr Trying) []stdio.Tool { return out } +// SeatInputTools are the seat's verb input (check.InputVerb): a checking program's input as the checker keeps it, asked by +// the Go build module that runs the program (buildstep.RunInput.Read), never read from the checker's state by it. +func SeatInputTools(r check.Reader) []stdio.Tool { + return []stdio.Tool{{Name: Seat + "." + meshdeliverychecker.VerbInput, + Description: "A checking program's input the checker keeps, by the sha256 of its exact bytes, as a run ask names it: the digest " + + "and the bytes, base64. A digest it keeps no input of, or bytes that read back as another digest, is an error. Read-only.", + Input: map[string]any{"type": "object", "required": []string{"digest"}, "properties": map[string]any{ + "digest": map[string]any{"type": "string", "description": "the input's sha256, 64 lowercase hex digits"}}}, + Run: func(args map[string]any) (any, error) { + digest, _ := args["digest"].(string) + return r.Input(digest) + }}} +} + // Tools are the read tools over the checker's states. func Tools(r check.Reader) []stdio.Tool { return []stdio.Tool{ {Name: check.VerdictsReadTool, - Description: "The verdicts the checker holds, each as stored under verdicts..: its state, every failure it names one line each (every member's failed tests, refused steps and conflicts, the manifests' refusals and the composition), the tests run, kept and skipped per level, the time of each part, the trunk heads it judged against and the composition, with the full " + + Description: "The verdicts the checker holds, each as stored under verdicts..: its state, every failure it names one line each (every member's failed tests, refused steps and conflicts, the manifests' refusals and the composition), the tests run, kept and skipped per level, the time of each part, the trunk heads it judged against, the composition and which checking program judged it (judged: its build and the heads it read), with the full " + "report of the status each pull request carries (the line the forge shows is cut at 140 characters, this is not). With a group, that group's only. Read-only.", Input: map[string]any{"type": "object", "properties": map[string]any{ "group": map[string]any{"type": "string", "description": "the delivery group's branch name"}}}, @@ -246,7 +261,7 @@ func Tools(r check.Reader) []stdio.Tool { return cutAnswer(map[string]any{"verdicts": vs}, cut), err }}, {Name: check.PullReadTool, - Description: "The verdict that judges one pull request, every failure it names one line each (all of them, not only the first the status line shows), and the full report of its status mesh/check. Read-only.", + Description: "The verdict that judges one pull request, every failure it names one line each (all of them, not only the first the status line shows), which checking program judged it (judged: its build, its key and the heads it read), and the full report of its status mesh/check. Read-only.", Input: map[string]any{"type": "object", "required": []string{"repository", "number"}, "properties": map[string]any{ "repository": map[string]any{"type": "string", "description": "/"}, "number": map[string]any{"type": "integer", "description": "the pull request's number"}}}, @@ -267,6 +282,17 @@ func Tools(r check.Reader) []stdio.Tool { kept, cut, err := r.Kept(level, digest) return cutAnswer(map[string]any{"kept": kept}, cut), err }}, + {Name: check.InputsReadTool, + Description: "The checking program's inputs the checker keeps, each under the sha256 of its exact bytes (inputs., kept a day): " + + "the facts of a check its verdict is asked of, or the heads its seat list is asked of. With a digest, that input as kept; " + + "without, every digest and its size. Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "digest": map[string]any{"type": "string", "description": "the input's sha256, as a run ask names it"}}}, + Run: func(args map[string]any) (any, error) { + digest, _ := args["digest"].(string) + inputs, cut, err := r.Inputs(digest) + return cutAnswer(map[string]any{"inputs": inputs}, cut), err + }}, {Name: check.PreviewsReadTool, Description: "The merge previews the checker holds, each as stored under previews...: the merge commit, per module directory " + "its tree, manifest digest and lock hashes, or the files in conflict. With a repository, that repository's; with a head (7 or more characters) " + @@ -328,9 +354,9 @@ func stepBound() time.Duration { func main() { r := check.Reader{VerdictState: read(stdio.State("verdicts")), KeptState: read(stdio.State("kept")), CandidateState: read(stdio.State("mesh-delivery-indexer.candidates")), - PreviewState: read(stdio.State("previews")), - StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))}, - HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(), + PreviewState: read(stdio.State("previews")), InputState: read(stdio.State(check.InputsStateName)), + StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))}, + HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(), TurnState: read(stdio.State("mesh-delivery-gate.turns")), HearingState: read(stdio.State(buildrun.HearingState)), AskState: read(stdio.State(check.AsksState)), BuildState: read(stdio.State("mesh-delivery-builder.builds"))} r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it @@ -376,6 +402,7 @@ func main() { // checker_first_views and checker_first_view_again: where the checker's first views of the indexer's and the assigner's // states stand, and asking one again by hand (ADR 0615, DLV-M2). tools = append(tools, views.StatusTools(check.CheckerModule, "checker_first_views", "checker_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) + tools = append(tools, SeatInputTools(r)...) if err := stdio.Serve("", logged(tools)); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err) os.Exit(1) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go index d3cb7e10..10a4bb90 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "encoding/json" "errors" "os" @@ -9,7 +10,9 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-checker/checkermsg" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "mesh-delivery-checker/internal/check" ) @@ -22,7 +25,7 @@ func TestTheToolsServedAreTheFiveReadTools(t *testing.T) { t.Errorf("%s has no description or no run", tl.Name) } } - want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool, check.PreviewsReadTool, check.StatusReadTool} + want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool, check.InputsReadTool, check.PreviewsReadTool, check.StatusReadTool} if !slices.Equal(names, want) { t.Errorf("tools = %v, want %v", names, want) } @@ -145,6 +148,21 @@ func TestTheTryToolsAskAndReadATry(t *testing.T) { // ADR 0501 §5, the agreed i t.Errorf("the verb try requires %v and says %q: want members as an alternative to one repository and head", v.Input.Required, v.Description) } } + // The seat's verb input (issue 1921, ADR 0596 §2): a checking program's input as kept, which the Go build module asks. + body := []byte(`{"v":1}`) + in, _ := buildstep.RunInputOf(check.InputVerb, body) + inputs := SeatInputTools(check.Reader{InputState: check.StateRead{Get: func(key string) (json.RawMessage, error) { + if key == in.Digest { + return body, nil + } + return nil, nil + }}}) + if len(inputs) != 1 || inputs[0].Name != Seat+".input" { + t.Fatalf("input verbs = %+v", inputs) + } + if a, err := inputs[0].Run(map[string]any{"digest": in.Digest}); err != nil || a.(meshdeliverychecker.InputAnswer).Input != base64.StdEncoding.EncodeToString(body) { + t.Errorf("verb input = %+v, %v", a, err) + } // A group try (issue 1921): the members, each an exact commit; members and one repository together are refused; the // description says what it does in the words a search for "try a group" finds. later := TryTools(Trying{Tries: store, Ender: store, Now: func() time.Time { return time.Unix(2, 5) }, diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/bus.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/bus.go index 52fe097a..d5323844 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/bus.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/bus.go @@ -183,3 +183,15 @@ func (a awaitedKV) Delete(ctx context.Context, key string) error { } return s.Delete(ctx, key) } + +// runInputs keeps the checking program's inputs in the checker's state inputs (check.InputsBucket), each under the +// sha256 of its exact bytes: an input already kept under its digest is the same bytes, so a second keep is no error. +type runInputs struct{ a awaitedKV } + +func (r runInputs) Keep(ctx context.Context, digest string, body []byte) error { + _, err := r.a.Create(ctx, digest, body) + if errors.Is(err, check.ErrConflict) { + return nil + } + return err +} diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go index 38bac683..537fa0b3 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go @@ -16,6 +16,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/fstest" "time" "github.com/nats-io/nats-server/v2/server" @@ -48,6 +49,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views/viewstest" + + "mesh-delivery-checker/rules" ) // busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the @@ -113,6 +116,118 @@ type fakeBuildModules struct { names []string // packages are, by repository, the package versions the last Go check ask of it carried (StepAsk.Packages). packages map[string]buildstep.PackageVersions + // runs counts the run asks answered, and broken, when set, is why the checking program does not build (issue 1921). + runs atomic.Int32 + broken atomic.Value + // drop, when set, drops every run ask unanswered and counts it in dropped, as a build module that does not know the job + // run (the lead's ruling on milestone 47/B0). + drop atomic.Bool + dropped atomic.Int32 +} + +// droppingAsks is the seat's asks as a build module that does not know the job run takes them: a run ask is acknowledged and +// never begun while the fake drops them. +type droppingAsks struct { + gobuild.AskSource + f *fakeBuildModules +} + +func (d droppingAsks) Next(ctx context.Context, kind string, wait time.Duration) (*gobuild.Pulled, error) { + msg, err := d.AskSource.Next(ctx, kind, wait) + if err != nil || msg == nil || !d.f.drop.Load() { + return msg, err + } + var a buildstep.StepAsk + if json.Unmarshal(msg.Data, &a) == nil && a.Run != nil { + d.f.dropped.Add(1) + return nil, msg.Ack() + } + return msg, nil +} + +// run answers a run ask as mesh-build-go does: the checking program's work (rules.Program) over the input the ask names, +// asked of the seat verb it names on the bus and checked against its digest (buildstep.RunInput.Read, as mesh-build-go +// reads it), with no heads of its own laid. +func (f *fakeBuildModules) run(ctx context.Context, a buildstep.StepAsk) (any, error) { + f.runs.Add(1) + if why, _ := f.broken.Load().(string); why != "" { + return nil, errors.New(why) + } + answer := buildstep.RunAnswer{Built: true, Key: "program-0123456789abcdef"} + // As mesh-build-go does, the input is asked before the program runs, and a step whose input cannot be had fails. + body, err := meshdeliverycheckerclient.ReadInput(func(key string, body any) (json.RawMessage, error) { return askVerb(f.js.Conn(), key, body) }, a.Run.Input.Digest) + if err == nil { + err = a.Run.Input.Verify(body) + } + if err != nil { + return nil, err + } + out, err := rules.Program(a.Run.Args, func() ([]byte, error) { return body, nil }, fstest.MapFS{}) + if err != nil { + answer.Exit, answer.Said = 2, err.Error() + } + answer.Output = out + return answer, nil +} + +// askVerb asks a seat verb on the subject that names no caller and reads the runtime's reply, {result, error}. +func askVerb(nc *nats.Conn, key string, body any) (json.RawMessage, error) { + seat, verb, ok := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + if !ok || !strings.HasPrefix(key, "seat:") { + return nil, fmt.Errorf("%q names no seat verb", key) + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + msg, err := nc.Request("mesh.seat."+seat+".tool."+verb, data, 5*time.Second) + if err != nil { + return nil, err + } + var r struct { + Result json.RawMessage `json:"result"` + Error string `json:"error"` + } + if err := json.Unmarshal(msg.Data, &r); err != nil { + return nil, err + } + if r.Error != "" { + return nil, errors.New(r.Error) + } + return r.Result, nil +} + +// serveInputVerb answers the checker seat's verb input on the bus as the checker's tools do (check.Reader.Input over the +// checker's state inputs), and counts what it answered (issue 1921, ADR 0596 §2). +func (b *bed) serveInputVerb(t *testing.T, nc *nats.Conn) { + t.Helper() + kv := b.buckets[check.InputsBucket] + r := check.Reader{InputState: check.StateRead{Get: func(key string) (json.RawMessage, error) { + e, err := kv.Get(b.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return e.Value(), nil + }}} + if _, err := nc.Subscribe("mesh.seat.mesh-delivery-checker.tool."+meshdeliverychecker.VerbInput, func(m *nats.Msg) { + var ask meshdeliverychecker.InputInput + reply := map[string]any{} + if err := json.Unmarshal(m.Data, &ask); err != nil { + reply["error"] = err.Error() + } else if a, err := r.Input(ask.Digest); err != nil { + reply["error"] = err.Error() + } else { + reply["result"] = a + b.inputsAnswered.Add(1) + } + data, _ := json.Marshal(reply) + _ = m.Respond(data) + }); err != nil { + t.Fatal(err) + } } func (f *fakeBuildModules) count(tool string) int { @@ -213,7 +328,7 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key raw, _ := value.(json.RawMessage) return kv.Put(f.ctx, key, raw) } - p := &gobuild.Puller{Source: &gobuild.JetStreamAsks{JS: f.js, Language: language}, Language: language, Node: "n1", Put: put, + p := &gobuild.Puller{Source: droppingAsks{&gobuild.JetStreamAsks{JS: f.js, Language: language}, f}, Language: language, Node: "n1", Put: put, Capacity: func() int { return 2 }, PullWait: 200 * time.Millisecond, Run: func(ctx context.Context, a buildstep.StepAsk) (any, error) { if a.Check != nil { @@ -227,6 +342,9 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key } f.mu.Unlock() } + if a.Run != nil { + return f.run(ctx, a) + } return gobuild.RunAsk(ctx, steps, a) }} go func() { _ = p.Pull(f.ctx) }() @@ -290,16 +408,18 @@ func mustRead(t *testing.T, name string) []byte { type bed struct { stalePin atomic.Bool // heads are the manifests the forge holds at a member's head, by " ". - heads sync.Map - ctx context.Context - js jetstream.JetStream - buckets map[string]jetstream.KeyValue - tools map[string]jetstream.KeyValue - fake *fakeBuildModules - srvURL string - announce []string - mu sync.Mutex - logs saidLog + heads sync.Map + ctx context.Context + js jetstream.JetStream + buckets map[string]jetstream.KeyValue + tools map[string]jetstream.KeyValue + // inputsAnswered counts the inputs the seat's verb input answered (issue 1921). + inputsAnswered atomic.Int32 + fake *fakeBuildModules + srvURL string + announce []string + mu sync.Mutex + logs saidLog } // saidLog is what the program said, safe for its goroutines and the test's. @@ -471,7 +591,7 @@ func startBed(t *testing.T) *bed { check.CheckerOwedBucket: 5, check.PreviewsBucket: 5, check.CandidatesBucket: 10, check.ModulesBucket: 20, check.ManifestsBucket: 1, check.SourceBucket: 64, check.PresenceBucket: 20, check.HaveBucket: 20, check.TriesBucket: 1, - check.TurnsBucket: 5, builds.BuilderBuildsBucket: 1} { + check.TurnsBucket: 5, builds.BuilderBuildsBucket: 1, check.InputsBucket: 1} { kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: uint8(history)}) if err != nil { t.Fatal(err) @@ -499,6 +619,7 @@ func startBed(t *testing.T) *bed { b.setHealth(t, "n1", module) } b.serveForge(t, ctl) + b.serveInputVerb(t, ctl) b.fake = &fakeBuildModules{js: js, ctx: ctx, calls: map[string]int{}} b.fake.serve(t, "mesh-build-go", b.buckets[check.CheckerKeptBucket]) b.fake.serve(t, "mesh-build-node", b.buckets[check.CheckerKeptBucket]) @@ -730,6 +851,19 @@ func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300 if got := asked(); len(got) == 0 || got[0].Candidate != key { t.Errorf("n1's engine was asked %+v, want the check's candidate %s", got, key) } + // Issue 1921 (B0): the verdict is the checking program's, built at the group's mesh-delivery head over its input kept in + // the state inputs and asked of the seat's verb input, two run asks a check (the seat list, the verdict); the verdict names the program's build. + if by := v.JudgedBy; by == nil || by.Program.Commit != head1 || by.Program.Package != check.CheckingProgramPackage || + by.Key != "program-0123456789abcdef" || len(by.Heads) != 1 || by.Heads[0].Commit != head1 { + t.Errorf("judged by %+v, want the checking program at the head %s", v.JudgedBy, head1) + } + if n := b.fake.runs.Load(); n != 4 { + t.Errorf("%d run asks, want two for each of the two checks", n) + } + // The operator's answer on B0: each run's input reached the build module through the seat's verb input on the bus. + if n := b.inputsAnswered.Load(); n != 4 { + t.Errorf("the verb input answered %d inputs, want one for each run ask", n) + } b.fake.mu.Lock() names := b.fake.names b.fake.mu.Unlock() @@ -834,28 +968,54 @@ func keptCount(t *testing.T, b *bed) int { return len(keys) } -// TestAHeadThatDoesNotMergeFailsNamingTheFilesAndRunsNoTest: the fetch answers the files in conflict; the verdict fails naming -// them, no test or digest step is run, a preview of the conflicts alone is written and the status is owed as failure. -func TestAHeadThatDoesNotMergeFailsNamingTheFilesAndRunsNoTest(t *testing.T) { // 1289/AC4 - b := startBed(t) - b.fake.conflict.Store(true) - key := b.candidate(t, "feat/x", pull(repo, 7, head1)) - b.start(t, nil) - v := b.verdict(t, key, ended) - if v.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(v.Failed.Words, "files in conflict: a.go, sub/b.go") { - t.Fatalf("verdict = %+v", v) - } - if o := b.owed(t, 7, head1, "failure"); !strings.HasPrefix(o.Text, "failed: n1: ") || !strings.Contains(o.Text, "files in conflict: a.go, sub/b.go") { - t.Errorf("owed = %+v", o) - } - if n := b.fake.count("mesh-build-go.build_go_digest") + b.fake.count("mesh-build-go.build_go_test"); n != 0 { - t.Errorf("%d digest and test steps ran after a conflict", n) - } - pk, _ := meshdeliverycheckerclient.PreviewKey(repo, head1, trunk) - if e, err := b.buckets[check.PreviewsBucket].Get(b.ctx, pk); err != nil { - t.Errorf("previews.%s: %v", pk, err) - } else if pv, err := meshdeliverycheckerclient.DecodePreview(e.Value()); err != nil || strings.Join(pv.Conflicts, ",") != "a.go,sub/b.go" || pv.Merge != "" { - t.Errorf("preview = %+v, %v, want the files in conflict alone", pv, err) +// TestACheckThatCannotBeJudgedFailsNamingWhy: a head that does not merge fails naming the files in conflict and runs no +// test (1289/AC4); a checking program whose run asks no build node begins, and one that does not build, fail the check +// naming the checking program, never judged by the running rules (issue 1921, the lead's ruling on milestone 47/B0: the +// begin bound is the checker's setting, shortened here). +func TestACheckThatCannotBeJudgedFailsNamingWhy(t *testing.T) { // 1289/AC4, 1921 + for _, row := range []struct { + name string + set func(f *fakeBuildModules) + env map[string]string + words string + tests bool // the digest and test steps ran + dropped int32 // the run asks dropped: the seat list's, put twice, and no verdict asked after it + program bool // the checking program judged it + }{ + {name: "a head that does not merge", set: func(f *fakeBuildModules) { f.conflict.Store(true) }, words: "files in conflict: a.go, sub/b.go", program: true}, + {name: "a run ask no build node begins", set: func(f *fakeBuildModules) { f.drop.Store(true) }, env: map[string]string{"MESH_CHECKER_BEGIN_BOUND": "500ms"}, + words: "no build node began the job run of the asks", tests: true, dropped: 2}, + {name: "a program that does not build", set: func(f *fakeBuildModules) { f.broken.Store("go build: undefined: rules.Nothing") }, + words: "by the checking program: its run ask", tests: true}, + } { + t.Run(row.name, func(t *testing.T) { + b := startBed(t) + row.set(b.fake) + key := b.candidate(t, "feat/x", pull(repo, 7, head1)) + b.start(t, row.env) + v := b.verdict(t, key, ended) + if v.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(v.Failed.Words, row.words) || (v.JudgedBy != nil) != row.program { + t.Fatalf("verdict = %+v, want a fail saying %q, judged by the program %v", v, row.words, row.program) + } + if o := b.owed(t, 7, head1, "failure"); !strings.HasPrefix(o.Text, "failed") || + !strings.Contains(o.Text, map[bool]string{true: row.words, false: "the checking program"}[row.program]) { + t.Errorf("owed = %+v", o) + } + if n := b.fake.count("mesh-build-go.build_go_digest") + b.fake.count("mesh-build-go.build_go_test"); (n != 0) != row.tests { + t.Errorf("%d digest and test steps ran, want them run %v", n, row.tests) + } + if n := b.fake.dropped.Load(); n != row.dropped { + t.Errorf("%d run asks dropped, want %d", n, row.dropped) + } + if !row.tests { + pk, _ := meshdeliverycheckerclient.PreviewKey(repo, head1, trunk) + if e, err := b.buckets[check.PreviewsBucket].Get(b.ctx, pk); err != nil { + t.Errorf("previews.%s: %v", pk, err) + } else if pv, err := meshdeliverycheckerclient.DecodePreview(e.Value()); err != nil || strings.Join(pv.Conflicts, ",") != "a.go,sub/b.go" || pv.Merge != "" { + t.Errorf("preview = %+v, %v, want the files in conflict alone", pv, err) + } + } + }) } } @@ -1137,7 +1297,7 @@ func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1 } if said.Module != "mesh-delivery-checker" || said.Node != "n1" || !said.Keeps("owed") || !said.Readable("owed") || !said.Readable("previews") || !said.Readable("verdicts") || !said.Readable("kept") || !said.Readable("tries") || said.Readable("hearing") || - !said.Keeps("asks") || said.Readable("asks") || !said.Keeps("first-views") || said.Readable("first-views") || !said.Keeps("findings") || len(said.States) != 9 { - t.Fatalf("the checker said %+v, want its nine states, asks, hearing and first-views its own (DLV-M2), with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said) + !said.Keeps("asks") || said.Readable("asks") || !said.Keeps("first-views") || said.Readable("first-views") || !said.Keeps("findings") || !said.Keeps("inputs") || said.Readable("inputs") || len(said.States) != 10 { + t.Fatalf("the checker said %+v, want its ten states, asks, hearing, first-views and inputs its own (DLV-M2), with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said) } } 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 6675c671..03799ba3 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -30,10 +30,14 @@ import ( // judgeDeps are the parts the judge of a check is made of. type judgeDeps struct { - asks check.Asks - renames check.Renames - seatDefs check.SeatDefinitions - // seats are the seat definitions the manifests are judged against (seatlist.go). + // inputs keeps the checking program's inputs (check.InputsStateName, served on check.InputVerb). + inputs check.Inputs + // beginBound is how long a run ask may wait for a build node to begin it; check.DefaultBeginBound when 0. + beginBound time.Duration + asks check.Asks + renames check.Renames + seatDefs check.SeatDefinitions + // seats are the seat list the checker's reader holds, the base the checking program lays the heads over (seatlist.go). seats check.SeatList tools *buildrun.ToolchainSet trunks *check.Trunks @@ -46,7 +50,6 @@ type judgeDeps struct { // published is the indexer's packages state as it is followed (packages.go). published *publishedPackages forge check.Forge - trees check.Trees announce check.Announcer builds check.BuildStates // ends waits on the builder's builds of a candidate before the check composes (ADR 0603 §10); nil composes without waiting. @@ -231,7 +234,7 @@ func newJudge(d judgeDeps) check.Judge { nodes = d.nodes } ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, - Kept: check.Kept{Store: d.kept}, Previews: d.previews, Candidates: d.candidates, Modules: d.repos, Packages: d.repos, Published: d.published, KnownPackages: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, SeatList: d.seats, Trees: d.trees, Logf: d.logf} + Kept: check.Kept{Store: d.kept}, Previews: d.previews, Candidates: d.candidates, Modules: d.repos, Packages: d.repos, Published: d.published, KnownPackages: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, SeatList: d.seats, Inputs: d.inputs, BeginBound: d.beginBound, Logf: d.logf} if d.nodes != nil && d.builds != nil && d.repos != nil { ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf} } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/kept_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/kept_test.go index 23f32299..c36810ad 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/kept_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/kept_test.go @@ -38,6 +38,7 @@ func TestTheCheckerAnnouncesEveryStateItKeepsAndOwedReadable(t *testing.T) { // t.Errorf("announced %v, the manifest keeps %v", announced, manifest) } // kept is read by the Go build module for a check ask (issue 1887 design limit 1); tries by the delivery view (build_usage). + // inputs are the checker's own: the Go build module asks the seat's verb input for one (ADR 0596 §2, issue 1921). if want := []string{"kept", "owed", "previews", "tries", "verdicts"}; !equal(readable, want) { t.Errorf("readable %v, want %v", readable, want) } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index 2687bedd..93353127 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -101,6 +101,10 @@ var keptStates = []announce.State{ {Name: "hearing"}, // The asks each running check put (check.AsksState): read by the checker's own checker_ask_again alone. {Name: "asks"}, + // The checking program's inputs by their digest (check.InputsStateName): the checker's own, read by its tools alone, + // which serve one on the seat's verb input to the Go build module that runs the program (check.InputVerb; ADR 0596 §2, + // issue 1921). + {Name: check.InputsStateName}, {Name: "kept", Readable: true}, {Name: "owed", Readable: true}, {Name: "previews", Readable: true}, @@ -187,6 +191,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err != nil { return err } + // The begin bound of a run ask of the checking program (check.DefaultBeginBound, issue 1921). + beginBound, err := durationEnv(getenv, "MESH_CHECKER_BEGIN_BOUND", check.DefaultBeginBound) + if err != nil { + return err + } link := strings.TrimSpace(getenv("MESH_CHECKER_LINK")) if link == "" { link = defaultLink @@ -215,6 +224,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") }) waiter := buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing} asksKV := process.AwaitBucket(ctx, js, conn, check.AsksBucket, "checker_ask_again refuses every check, as it cannot tell whether its ask waits in a stream", logger.Printf) + inputsKV := process.AwaitBucket(ctx, js, conn, check.InputsBucket, "no checking program can be asked, so every check fails naming it", logger.Printf) ch := &check.Checker{ Asks: awaitedKV{asksKV}, Verdicts: kvStore{buckets[check.CheckerVerdictsBucket]}, @@ -290,8 +300,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro ch.Judge = newJudge(judgeDeps{ asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks, kept: kvStore{buckets[check.CheckerKeptBucket]}, previews: kvStore{buckets[check.PreviewsBucket]}, candidates: tryCandidates{candidates}, - repos: repos, published: published, builds: buildStates, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, trees: forgeReads{call: caller.Call}, logf: logger.Printf, - announce: announce, compositions: compositions, tags: tags, + repos: repos, published: published, builds: buildStates, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf, + announce: announce, compositions: compositions, tags: tags, inputs: runInputs{awaitedKV{inputsKV}}, beginBound: beginBound, nodes: openNodeNames(ctx, js, logger.Printf), predefined: keptPredefined{predefined}, renames: seatRenames{call: caller.Call}, diff --git a/modules/mesh-delivery-checker/internal/check/binaries.go b/modules/mesh-delivery-checker/internal/check/binaries.go index d649b757..21abbb51 100644 --- a/modules/mesh-delivery-checker/internal/check/binaries.go +++ b/modules/mesh-delivery-checker/internal/check/binaries.go @@ -2,31 +2,26 @@ package check import ( "fmt" - "strings" + + "mesh-delivery-checker/rules" ) // A change that adds or modifies a compiled program fails (issue 1577): it is build output, `go build` in a module's folder // writes one beside go.mod, and `git add -A` takes it. The build module's fetch step, which holds the merged clone, reads the // first bytes of each changed file and reports the programs (the build modules' fetch tools); the checker judges the answer. -// checkBinariesOf fails a pull request whose change adds or modifies a compiled program, naming each file. It judges the -// first fetch answer of the pull request, whichever build module gave it. A fetch that made the merge and names no program has -// none: the answer's `programs` is an omitted empty list (B2g), and `merge` tells a preview was made. -func (st *state) checkBinariesOf(t *target) verdictPtr { +// checkBinariesOf records what a pull request's first fetch answer read of compiled programs, whichever build module +// gave it, as a fact the rules judge (rules.JudgePrograms). A fetch that made the merge and names no program has none: the +// answer's `programs` is an omitted empty list (B2g), and `merge` tells a preview was made. +func (st *state) checkBinariesOf(t *target) { key := fmt.Sprintf("%s#%d", t.pull.Repository, t.pull.Number) if t.fetched.Merge == "" { - return nil // no merge was made: nothing was changed to judge + return // no merge was made: nothing was changed to judge } - var found []string - for _, p := range t.fetched.Programs { - if p.Commit != "" { - found = append(found, fmt.Sprintf("%s: a compiled program added in %s and removed later: it stays in history; squash the branch (issue 1577)", p.Path, short12(p.Commit))) - continue - } - found = append(found, p.Path+": a compiled program is build output: remove it and add it to .gitignore (issue 1577)") + files := t.fetched.Programs + if files == nil { + files = []ProgramFile{} // reported, and none: the rules refuse nothing (a nil list is a module that reports none) } - if len(found) > 0 { - return failed(t.node, fmt.Sprintf("%s: %s", key, strings.Join(found, "; "))) - } - return nil + st.facts = append(st.facts, rules.Step{Repository: t.pull.Repository, Number: t.pull.Number, Module: t.module, Node: t.node, + Programs: &rules.Programs{Key: key, Files: &files}}) } diff --git a/modules/mesh-delivery-checker/internal/check/builds.go b/modules/mesh-delivery-checker/internal/check/builds.go index 8fe9abf8..f26ae985 100644 --- a/modules/mesh-delivery-checker/internal/check/builds.go +++ b/modules/mesh-delivery-checker/internal/check/builds.go @@ -125,19 +125,20 @@ func (j *Examiner) buildWait() time.Duration { // awaitBuilds waits for the candidate's builds before the check composes (ADR 0603 §10): the check builds what the builder // builds of the candidate and composes with it. A failed build fails the check, naming the module and the builder's words; // a build not ended within the bound is named, and the check composes without it. It answers the words of the composition's -// warnings: what the check composes without waiting on its build, and why. -func (st *state) awaitBuilds(ctx context.Context, v *meshdeliverychecker.VerdictsByGroupHeadsValue) []string { +// warnings (what the check composes without waiting on its build, and why) and the failures of the builds that failed, for +// the rules to take in (rules.JudgeComposition); failed says the check has failed already. +func (st *state) awaitBuilds(ctx context.Context, failed bool) ([]string, []FailedFirst) { wait, words := st.buildsOfTheCheck(ctx) if len(wait) == 0 { - return words + return words, nil } modules := slices.Sorted(maps.Keys(wait)) - if v.State == meshdeliverycheckerclient.VerdictFail { + if failed { // The check has failed already: its red status is never held back by a build (the lead, 2026-11-03). - return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", "))) + return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", "))), nil } if st.j.BuildEnds == nil { - return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "))) + return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "))), nil } bound := st.j.buildWait() waits := fmt.Sprintf("waits on the builder's builds of %s before it composes, at most %s (ADR 0603 §10)", strings.Join(modules, ", "), bound) @@ -148,25 +149,21 @@ func (st *state) awaitBuilds(ctx context.Context, v *meshdeliverychecker.Verdict cancel() if err != nil { st.j.logf("%s: the builds of %s cannot be heard: %v", st.c.Key, strings.Join(modules, ", "), err) - return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err)) + return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err)), nil } var late []string + var failedBuilds []FailedFirst for _, module := range modules { b, done := ended[module] switch { case !done: late = append(late, module) case b.State == builds.BuildFailed: - f := FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)} - if v.State != meshdeliverycheckerclient.VerdictFail { - v.State, v.Failed = meshdeliverycheckerclient.VerdictFail, &f - continue - } - addFailure(v, f) + failedBuilds = append(failedBuilds, FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)}) } } if len(late) > 0 && ctx.Err() == nil { words = append(words, fmt.Sprintf("the builds of %s did not end within the build-wait-bound of %s, so the check composes without them (ADR 0603 §10)", strings.Join(late, ", "), bound)) } - return words + return words, failedBuilds } diff --git a/modules/mesh-delivery-checker/internal/check/case.go b/modules/mesh-delivery-checker/internal/check/case.go index 882fafaf..ca2e52bd 100644 --- a/modules/mesh-delivery-checker/internal/check/case.go +++ b/modules/mesh-delivery-checker/internal/check/case.go @@ -44,11 +44,15 @@ type Case struct { Left []string // Queued is when the checker took the case up, for the time of its queue. Queued time.Time + // Seats is the seat list the checking program made, the checker's reader's list with the heads' definitions laid over + // it, which the manifest validator's and the node-engine composer's requests carry (ADR 0636 §1, §4); none until it is + // made. + Seats []json.RawMessage // Try narrows a try's check (its level, its tests); nil for a pull request's. Try *TryOptions - // Seats is the seat list the check composes against (state.checkSeats), raw, handed to the composer of a node-engine - // member's head; nil when the checker's reader has not read its list. - Seats []json.RawMessage + // Tagged are the group's tagged versions of its Go packages the check heard (design 51 *Tagged versions*), nil for none: + // a composer built at the node-engine member's head is built against them, as the check's other Go builds are. + Tagged buildstep.PackageVersions notesMu sync.Mutex // warnings are, by pull request, what part of its check did not run (Case.warn). diff --git a/modules/mesh-delivery-checker/internal/check/checker.go b/modules/mesh-delivery-checker/internal/check/checker.go index ebe06713..ffe77839 100644 --- a/modules/mesh-delivery-checker/internal/check/checker.go +++ b/modules/mesh-delivery-checker/internal/check/checker.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "maps" + "mesh-delivery-checker/rules" "slices" "sort" "strings" @@ -464,7 +465,7 @@ func (ch *Checker) untrunked(ctx context.Context, c meshcatalog.CandidatesByGrou continue } o := meshdeliverychecker.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: meshdeliveryclient.StatusContextCheck, State: "error", - Target: strings.TrimSuffix(ch.Link, "/") + "/" + key, Text: clipLine(words, MaxLine)} + Target: strings.TrimSuffix(ch.Link, "/") + "/" + key, Text: rules.ClipLine(words, MaxLine)} data, err := EncodeCheckerOwed(o) if err != nil { ch.logf("%s: the status of %s cannot be written: %v", key, okey, err) diff --git a/modules/mesh-delivery-checker/internal/check/composition.go b/modules/mesh-delivery-checker/internal/check/composition.go deleted file mode 100644 index 3baf5fa0..00000000 --- a/modules/mesh-delivery-checker/internal/check/composition.go +++ /dev/null @@ -1,42 +0,0 @@ -package check - -import ( - "strings" - - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" -) - -// withComposition is v with the check's composition taken in: a failed one fails the check, naming its machine. -func withComposition(v *meshdeliverychecker.VerdictsByGroupHeadsValue, comp meshwants.CompositionsByGroupHeadsValue) { - v.Composition = compositionOf(comp) - if v.Took == nil { - v.Took = &meshdeliverychecker.VerdictsByGroupHeadsValueTook{} - } - v.Took.Composition = comp.Took - if comp.State == meshdeliverycheckerclient.CompositionPass && len(comp.Warnings) > 0 && v.Composition != nil { - v.Composition.Words = strings.Join(comp.Warnings, "; ") // passed with a warning (ADR 0593 §9) - } - if comp.State == meshdeliverycheckerclient.CompositionFail { - machine := comp.Machine - if machine == "" { - machine = "the composition" - } - f := FailedFirst{Machine: machine, Words: comp.Words} - if v.State == meshdeliverycheckerclient.VerdictFail { - addFailure(v, f) // the tests failed too: the composition is named after them (the operator, 2026-11-02) - return - } - v.State, v.Failed = meshdeliverycheckerclient.VerdictFail, &f - } -} - -// compositionOf is what the checker writes into its verdict from the assigner's composition answer c: nil while c waits (the -// check is still running). -func compositionOf(c meshwants.CompositionsByGroupHeadsValue) *meshdeliverychecker.VerdictsByGroupHeadsValueComposition { - if c.State == compositionWaits { - return nil - } - return &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: c.State, Words: c.Words, Sdk: c.Sdk, Machines: c.Machines} -} diff --git a/modules/mesh-delivery-checker/internal/check/composition_test.go b/modules/mesh-delivery-checker/internal/check/composition_test.go index 174f85be..28b435d3 100644 --- a/modules/mesh-delivery-checker/internal/check/composition_test.go +++ b/modules/mesh-delivery-checker/internal/check/composition_test.go @@ -5,22 +5,24 @@ import ( "encoding/json" "errors" "maps" + "mesh-delivery-checker/rules" "slices" "strings" "testing" + nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" buildsrec "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" - "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/catalogue" ) // fakeCompositions answers a check's composition from memory, recording the keys asked. type fakeCompositions struct { - v meshwants.CompositionsByGroupHeadsValue + v rules.Composed err error asked []string // seats are the seat lists the cases asked carried (Case.Seats), one per ask. @@ -29,22 +31,37 @@ type fakeCompositions struct { waits bool } -func (f *fakeCompositions) Compose(ctx context.Context, c *Case, _ buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error) { +func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) { f.asked = append(f.asked, c.Key) f.seats = append(f.seats, c.Seats) if f.waits { <-ctx.Done() - return meshwants.CompositionsByGroupHeadsValue{}, ctx.Err() + return rules.Composed{}, ctx.Err() } return f.v, f.err } +// composedOn is the engines' answers of a composition by composer on the nodes, each composing it, taking took ms. +func composedOn(composer string, took int64, nodes ...string) rules.Composed { + c := rules.Composed{Group: "feat/x", Heads: "0123456789ab", Composer: composer, Nodes: nodes, Took: took, Answers: map[string]ne.ComposeCandidateAnswer{}} + for _, n := range nodes { + c.Answers[n] = ne.ComposeCandidateAnswer{Node: n, Verdict: nec.Composed} + } + return c +} + +// refusedOn is the engines' answers of a composition the node refuses with the words. +func refusedOn(composer, node, words string) rules.Composed { + return rules.Composed{Group: "feat/x", Heads: "0123456789ab", Composer: composer, Nodes: []string{node}, + Answers: map[string]ne.ComposeCandidateAnswer{node: {Node: node, Verdict: nec.Refused, Refused: words}}} +} + // 1297/AC3 (its unit half): a check whose tests pass takes the assigner's composition of it, by the check's own key, // into its verdict: a pass says it passed with the mesh-sdk and machines it composed, and is no longer "not run". func TestAPassingCheckTakesTheAssignersComposition(t *testing.T) { t.Run("a change to a build module or base names the modules built on it", aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt) b := newBench(t) - f := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.CompositionPass, Sdk: "v0.1.14-x", Machines: []string{"g14"}, Took: 40}} + f := &fakeCompositions{v: composedOn("v0.1.14-x", 40, "g14")} b.ex.Compositions = f cs := caseOf(t, pullOf(repoA, 7, head1)) cs.FPSet = "fps-of-the-candidate" @@ -68,26 +85,25 @@ func TestAPassingCheckTakesTheAssignersComposition(t *testing.T) { } for _, read := range []bool{true, false} { b.ex.SeatList = fakeSeatList{read: read} - b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) - got := f.seats[len(f.seats)-1] - if want := 0; read { - want = 2 - if len(got) != want || string(got[0]) != `{"seat":"a"}` { + asked := len(f.seats) + rv := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict + if read { + if got := f.seats[len(f.seats)-1]; len(got) != 2 || string(got[0]) != `{"seat":"a"}` { t.Errorf("with the list read the composition was handed %s, want the reader's two definitions", got) } - } else if len(got) != want { - t.Errorf("with the list not read the composition was handed %s, want none", got) + } else if len(f.seats) != asked || rv.Failed == nil || !strings.Contains(rv.Failed.Words, "the seat list is not read") { + // A reader that has not read the list acts on nothing new (ADR 0636 §1): the check ends before any judge. + t.Errorf("with the list not read the composition was asked %d more, verdict %+v; want none and a fail naming the list", len(f.seats)-asked, rv.Failed) } } - // ADR 0635 §3: at a node-engine member's head a list not read fails the composition, naming why, and asks no composer + // ADR 0635 §3: at a node-engine member's head a list not read fails the check, naming why, and asks no composer // (a composer handed none would judge no machine and the composition would pass); read, it composes. for _, read := range []bool{false, true} { b.ex.SeatList = fakeSeatList{read: read} asked := len(f.asked) hv := b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf(nodeEngineRepo, 3, head2))).Verdict switch { - case !read && (hv.State != meshdeliverycheckerclient.VerdictFail || hv.Composition == nil || hv.Composition.State != meshdeliverycheckerclient.CompositionFail || - !strings.Contains(hv.Composition.Words, "seat list") || len(f.asked) != asked): + case !read && (hv.State != meshdeliverycheckerclient.VerdictFail || hv.Failed == nil || !strings.Contains(hv.Failed.Words, "seat list") || len(f.asked) != asked): t.Errorf("at a node-engine head with the list not read: verdict %+v, composition %+v, asked %d more; want a failed composition naming the seat list, no ask", hv, hv.Composition, len(f.asked)-asked) case read && (hv.Composition == nil || hv.Composition.State != meshdeliverycheckerclient.CompositionPass || len(f.asked) != asked+1): t.Errorf("at a node-engine head with the list read: composition %+v, want the composer's pass", hv.Composition) @@ -110,8 +126,7 @@ func compositionMentions(v meshdeliverychecker.VerdictsByGroupHeadsValue, word s // 1297/AC2 at the checker: a failed composition fails the check, naming the machine it failed at and its words. func TestAFailedCompositionFailsTheCheck(t *testing.T) { b := newBench(t) - b.ex.Compositions = &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.CompositionFail, Sdk: "x", - Machine: "g14", Words: "g14 runs web, which the change removes from its source"}} + b.ex.Compositions = &fakeCompositions{v: refusedOn("x", "g14", "g14 runs web, which the change removes from its source")} v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict if v.State != meshdeliverycheckerclient.VerdictFail || v.Failed.Machine != "g14" || v.Composition == nil || v.Composition.State != meshdeliverycheckerclient.CompositionFail { t.Fatalf("verdict = %+v", v) @@ -132,7 +147,7 @@ func TestACompositionNotHadIsNotRun(t *testing.T) { t.Fatalf("verdict = %+v", v) } b2 := newBench(t) - f2 := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{State: meshdeliverycheckerclient.CompositionFail, Words: "w", Sdk: "x"}} + f2 := &fakeCompositions{v: refusedOn("x", "g14", "w")} b2.ex.Compositions = f2 cs := caseOf(t, pullOf(repoA, 7, head1)) cs.Try = &TryOptions{} @@ -255,7 +270,7 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { return r, nil } } - comps := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.CompositionPass, Sdk: "x", Machines: []string{"g14"}}} + comps := &fakeCompositions{v: composedOn("x", 0, "g14")} b.ex.Compositions = comps var ends *fakeEnds if row.builds != nil { @@ -308,3 +323,5 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { } } } + +func ptr[T any](v T) *T { return &v } diff --git a/modules/mesh-delivery-checker/internal/check/engines.go b/modules/mesh-delivery-checker/internal/check/engines.go index 600556df..ec361c44 100644 --- a/modules/mesh-delivery-checker/internal/check/engines.go +++ b/modules/mesh-delivery-checker/internal/check/engines.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "mesh-delivery-checker/rules" "sort" "strings" "sync" @@ -13,9 +14,7 @@ import ( nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-delivery/sdk/composer" ) @@ -75,17 +74,18 @@ func composerHead(c *Case) string { return "" } -// Compose asks every placed node and judges their answers (JudgeEngines). An error is only a case it cannot ask at all. -func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error) { +// Compose asks every placed node and answers their answers, for the rules to judge (rules.JudgeEngines). An error is only a +// case it cannot ask at all. +func (e EngineCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) { began := time.Now() key, err := nodeengineclient.CompositionKey(c.Group, c.Heads) if err != nil { - return meshwants.CompositionsByGroupHeadsValue{}, err + return rules.Composed{}, err } var nodes []string if e.Placed != nil { if nodes, err = e.Placed(); err != nil { - return meshwants.CompositionsByGroupHeadsValue{}, fmt.Errorf("the assignments cannot be read, so no node can be asked: %w", err) + return rules.Composed{}, fmt.Errorf("the assignments cannot be read, so no node can be asked: %w", err) } } bound := e.Bound @@ -109,7 +109,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildst // The composer's build and run are the check's to bound, not the engine's answer bound. A composer that // cannot run is no silent node (ADR 0593 §9 lets only that pass): the node's composition fails, naming // the head. - if composed, serr := e.inSandbox(ctx, c, tagged, head, node, a); serr != nil { + if composed, serr := e.inSandbox(ctx, c, head, node, a); serr != nil { a = ne.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: nec.Refused, Refused: "the composer at the node-engine's head " + head + " could not compose it: " + serr.Error()} } else { @@ -136,7 +136,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildst } else if e.Composer != nil && e.Composer() != "" { composerName = e.Composer() } - v := JudgeEngines(c.Group, c.Heads, composerName, nodes, answers, silent) + v := rules.Composed{Group: c.Group, Heads: c.HeadsHash, Composer: composerName, Nodes: nodes, Answers: answers, Silent: silent} if head != "" && e.Sandbox == nil { v.Warnings = append(v.Warnings, "the group changes the node-engine's composition code and was composed by the running engines") } @@ -144,61 +144,10 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildst return v, nil } -// JudgeEngines is the check's composition verdict from the engines' answers (ADR 0593 §7, §9): failed when any node's -// answer is refused, naming each node and each module refused with its words (the first refusing node is the machine); -// passed otherwise. Every node in nodes without an answer (silent, by node with why, for the journal only) is named -// with nec.NoAnswer as a warning. composer names what composed, for the line. Pure. -func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]ne.ComposeCandidateAnswer, silent map[string]string) meshwants.CompositionsByGroupHeadsValue { - v := meshwants.CompositionsByGroupHeadsValue{Group: group, Heads: heads, State: meshdeliverycheckerclient.CompositionPass, Sdk: composerName} - sorted := append([]string(nil), nodes...) - sort.Strings(sorted) - var fails []string - for _, node := range sorted { - a, answered := answers[node] - if !answered { - v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer) - continue - } - if a.Verdict == nec.NotJudged { - // Composed in the sandbox and not judged (ADR 0593 §9): named as no answer, with the composer's words; decided by the - // verdict, never by the words (ADR 0601 §4's kind). - v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer+" ("+a.Refused+")") - continue - } - v.Machines = append(v.Machines, node) - if a.Verdict != nec.Refused { - continue - } - if v.Machine == "" { - v.Machine = node - } - var words []string - if a.Refused != "" { - words = append(words, a.Refused) - } - for _, m := range append(append([]ne.Module(nil), a.Modules...), a.Own) { - if m.State == nec.ModuleRefused { - words = append(words, m.Module+": "+m.Words) - } - } - if len(words) == 0 { - words = append(words, "refused, naming no module") - } - fails = append(fails, "not composed on "+node+": "+strings.Join(words, "; ")) - } - if len(fails) > 0 { - v.State, v.Words = meshdeliverycheckerclient.CompositionFail, strings.Join(fails, "; ") - } - if len(sorted) == 0 { - v.Warnings = append(v.Warnings, "no node is placed, so the change was composed on none") - } - return v -} - // inSandbox composes a node's answer again with the composer built from the node-engine member's head, over the input the // node's engine composed from: the node's answer becomes the sandbox's, refused with the composer's words where the // composer refuses the node. -func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, tagged buildstep.PackageVersions, head, node string, a ne.ComposeCandidateAnswer) (ne.ComposeCandidateAnswer, error) { +func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node string, a ne.ComposeCandidateAnswer) (ne.ComposeCandidateAnswer, error) { if len(a.Input) == 0 { return a, fmt.Errorf("the engine of %s answered no composition input, so the node-engine's head cannot compose it", node) } @@ -242,7 +191,7 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, tagged build if err != nil { return a, err } - job := buildstep.ComposeJob{Fetch: buildstep.Fetch{Repository: nodeEngineRepo, Commit: head, Packages: packagesFor(e.Published, c, tagged, nodeEngineRepo)}, Dir: meshcatalogclient.RootPath, + job := buildstep.ComposeJob{Fetch: buildstep.Fetch{Repository: nodeEngineRepo, Commit: head, Packages: packagesFor(e.Published, c, c.Tagged, nodeEngineRepo)}, Dir: meshcatalogclient.RootPath, Package: composer.Package, Request: raw, Reader: "the node-engine head of " + c.Group} verdict, err := e.Sandbox.Compose(ctx, job) if err != nil { diff --git a/modules/mesh-delivery-checker/internal/check/engines_test.go b/modules/mesh-delivery-checker/internal/check/engines_test.go index 5f41d060..a06a455a 100644 --- a/modules/mesh-delivery-checker/internal/check/engines_test.go +++ b/modules/mesh-delivery-checker/internal/check/engines_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "mesh-delivery-checker/rules" "strings" "sync" "testing" @@ -87,15 +88,17 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { } cs := caseOf(t, pullOf(repoA, 7, head1)) key, _ := nodeengineclient.CompositionKey(cs.Group, cs.HeadsHash) + var last rules.Composed // the engines' answers of the last compose, for the check's verdict to take in compose := func(e *fakeEngines, nodes ...string) meshwants.CompositionsByGroupHeadsValue { t.Helper() ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return nodes, nil }, Bound: 50 * time.Millisecond, Composer: func() string { return strings.Repeat("7", 40) }} - v, err := ec.Compose(context.Background(), cs, nil) + raw, err := ec.Compose(context.Background(), cs) if err != nil { t.Fatal(err) } - return v + last = raw + return rules.JudgeEngines(raw) } t.Run("one node refusing fails the composition, naming the node and the module", func(t *testing.T) { @@ -113,7 +116,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { t.Errorf("asked %+v, want the check's candidate %s and no input", e.asked, key) } var ver meshdeliverychecker.VerdictsByGroupHeadsValue - withComposition(&ver, v) + rules.JudgeComposition(&ver, rules.Composition{Composed: &last}) if ver.State != meshdeliverycheckerclient.VerdictFail || ver.Failed.Machine != "laptop" { t.Errorf("the check's verdict = %+v", ver) } @@ -138,7 +141,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { } var ver meshdeliverychecker.VerdictsByGroupHeadsValue ver.State = meshdeliverycheckerclient.VerdictPass - withComposition(&ver, v) + rules.JudgeComposition(&ver, rules.Composition{Composed: &last}) if ver.State != meshdeliverycheckerclient.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+nec.NoAnswer) { t.Errorf("the line = %q", composition(ver.Composition, 0, false)) } @@ -150,6 +153,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { t.Fatal(err) } host := CaseOf(c, map[string]string{repoA: trunkA, nodeEngineRepo: trunkA}) + host.Seats = []json.RawMessage{json.RawMessage(`{"seat":"node-x","scope":"node"}`)} // the heads' list (ADR 0636 §4) withInput := composed("anchor") withInput.Input = map[string]any{"nodes": []any{map[string]any{"name": "anchor"}}} // Issue 1966 (the group try of 2026-11-06 10:07): the composer at the head composes against the check's seat list. @@ -161,7 +165,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { }} ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return []string{"anchor"}, nil }, Bound: time.Second, Sandbox: sb, Manifests: digests{"web": "sha256:" + strings.Repeat("c", 64)}, Published: published{}} - v, err := ec.Compose(context.Background(), host, nil) + raw, err := ec.Compose(context.Background(), host) + v := rules.JudgeEngines(raw) if err != nil { t.Fatal(err) } @@ -173,7 +178,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { _ = json.Unmarshal(job.Request, &req) if job.Fetch.Repository != nodeEngineRepo || job.Fetch.Commit != head2 || job.Package != composer.Package || string(req.Input) != `{"nodes":[{"name":"anchor"}]}` || req.Cases[0].Manifests["web"].Digest == "" || - len(req.Seats) != 1 || string(req.Seats[0]) != `{"format":1,"seat":"node-uplink"}` { + len(req.Seats) != 1 || string(req.Seats[0]) != string(host.Seats[0]) { t.Errorf("job = %+v, request %+v", job, req) } // Issue 1921: the composer is built at the group's heads, also a member that publishes no package yet. @@ -183,7 +188,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { // ADR 0607 §3, §5, design 51 *Tagged versions*: once the check heard the group's tagged version of a member's Go // package, the composer is built against that, not the member's head, as the check's other Go builds are. const tagged = "v0.1.15-feat-x.1" - if _, err := ec.Compose(context.Background(), host, buildstep.PackageVersions{trunk.ForgeModulePrefix + "mesh-delivery": tagged}); err != nil { + host.Tagged = buildstep.PackageVersions{trunk.ForgeModulePrefix + "mesh-delivery": tagged} + if _, err := ec.Compose(context.Background(), host); err != nil { t.Fatal(err) } if pkgs := sb.jobs[len(sb.jobs)-1].Fetch.Packages; len(pkgs) != 1 || pkgs[trunk.ForgeModulePrefix+"mesh-delivery"] != tagged { @@ -205,7 +211,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { sb.v = func(node string) composer.Verdict { return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change", Machines: map[string]composer.Machine{node: row.m}}}} } - v, err = ec.Compose(context.Background(), host, nil) + raw, err = ec.Compose(context.Background(), host) + v = rules.JudgeEngines(raw) named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+nec.NoAnswer+" (its node-engine sends no running state for web)") if err != nil || (v.State == meshdeliverycheckerclient.CompositionPass) != row.pass || named != row.pass { t.Errorf("%+v: verdict %+v, %v", row.m, v, err) @@ -214,7 +221,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { // Review finding 3 (ADR 0593 §9 lets only a node that does not answer pass): a composer at the head that cannot // run fails the composition, naming the head, never passing as a silent node. sb.err = errors.New("the build of mesh-compose failed") - v, err = ec.Compose(context.Background(), host, nil) + raw, err = ec.Compose(context.Background(), host) + v = rules.JudgeEngines(raw) if err != nil || v.State != meshdeliverycheckerclient.CompositionFail || !strings.Contains(v.Words, head2) || !strings.Contains(v.Words, "the build of mesh-compose failed") || len(v.Warnings) != 0 { t.Errorf("a composer that cannot run: %+v, %v", v, err) @@ -224,7 +232,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { // Review finding 4: assignments that cannot be read is no mesh with no node: the composition is not made, said. t.Run("an unreadable assignments makes no composition", func(t *testing.T) { ec := EngineCompositions{Asker: &fakeEngines{}, Placed: func() ([]string, error) { return nil, errors.New("no such bucket") }} - if _, err := ec.Compose(context.Background(), cs, nil); err == nil || !strings.Contains(err.Error(), "no such bucket") { + if _, err := ec.Compose(context.Background(), cs); err == nil || !strings.Contains(err.Error(), "no such bucket") { t.Errorf("an unreadable assignments: %v", err) } b := newBench(t) diff --git a/modules/mesh-delivery-checker/internal/check/every_failure_test.go b/modules/mesh-delivery-checker/internal/check/every_failure_test.go index 88172630..f9666ea2 100644 --- a/modules/mesh-delivery-checker/internal/check/every_failure_test.go +++ b/modules/mesh-delivery-checker/internal/check/every_failure_test.go @@ -3,12 +3,12 @@ package check import ( "encoding/json" "fmt" + "mesh-delivery-checker/rules" "strings" "testing" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) @@ -24,9 +24,17 @@ func failingResult(t *testing.T, tests int, failed ...Failure) json.RawMessage { func judgeLevel(t *testing.T, tests ...json.RawMessage) (*meshdeliverychecker.VerdictsByGroupHeadsValue, *state) { t.Helper() - st := &state{j: &Examiner{}, c: &Case{}, levels: map[string]*acc{buildstep.LevelUnit: {}, buildstep.LevelIntegration: {}}, nodes: map[string]bool{}} - v, _ := st.level(t.Context(), &target{pull: Pull{Repository: "novox/x"}}, buildstep.LevelAnswer{Level: buildstep.LevelIntegration, Tests: tests}) - return settled(joinFailures(v)), st + st := &state{j: &Examiner{}, c: &Case{}, levels: rules.NewTally()} + st.level(t.Context(), &target{pull: Pull{Repository: "novox/x"}}, buildstep.LevelAnswer{Level: buildstep.LevelIntegration, Tests: tests}) + // 1921 (B0, D1a): the running checker only gathers the level as a fact; the rules judge it, and count what it kept. + judged := rules.Judge(rules.Facts{V: rules.FactsVersion, Steps: st.facts}) + if judged.Levels[buildstep.LevelIntegration] != (meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{Run: int64(st.levels[buildstep.LevelIntegration].Run)}) { + t.Errorf("the rules count %+v, the running checker %+v", judged.Levels, *st.levels[buildstep.LevelIntegration]) + } + if judged.State == meshdeliverycheckerclient.VerdictPass { + return nil, st + } + return &judged, st } // Issue 1921: two failing tests in two packages are two entries of the verdict's Failures and two lines of a try's @@ -51,7 +59,7 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) { if len(v.Failures) != 2 || v.Failed.Test != "integration: x/a.TestA" || !strings.Contains(v.Failed.Words, "a_test.go:3: logged") { t.Errorf("%s: verdict %+v", name, v) } - if run := st.levels[buildstep.LevelIntegration].run; run != 10 { + if run := st.levels[buildstep.LevelIntegration].Run; run != 10 { t.Errorf("%s: %d tests counted, want all 10", name, run) } if _, err := meshdeliverycheckerclient.EncodeVerdict(meshdeliverychecker.VerdictsByGroupHeadsValue{Group: "g", Heads: "0123456789ab", State: v.State, Failed: v.Failed, Failures: v.Failures}); err != nil { @@ -74,7 +82,7 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) { } // Issue 1921: past meshdeliverycheckerclient.MaxFailures the list names the first fifty and how many more, the status line says how many -// failed and names the first, cut so "and N more" stays whole, and each entry's line is cut to MaxFailureLine. +// failed and names the first, cut so "and N more" stays whole, and each entry's line is cut to rules.MaxFailureLine. func TestFailuresAreCappedWithHowManyMore(t *testing.T) { var fs []Failure for i := range 60 { @@ -84,10 +92,10 @@ func TestFailuresAreCappedWithHowManyMore(t *testing.T) { if len(v.Failures) != meshdeliverycheckerclient.MaxFailures+1 || v.Failures[meshdeliverycheckerclient.MaxFailures].Test != "and 10 more" { t.Fatalf("%d entries, last %+v", len(v.Failures), v.Failures[len(v.Failures)-1]) } - if len(v.Failures[0].Words) > MaxFailureLine { + if len(v.Failures[0].Words) > rules.MaxFailureLine { t.Errorf("an entry's line is %d bytes", len(v.Failures[0].Words)) } - if n := failedCount(*v); n != 60 { + if n := rules.FailedCount(*v); n != 60 { t.Errorf("failedCount = %d, want 60", n) } cs := &Case{Pulls: []Pull{{Repository: "novox/x", Head: "0123456789abcdef", Trunk: "fedcba9876543210"}}} @@ -96,8 +104,9 @@ func TestFailuresAreCappedWithHowManyMore(t *testing.T) { t.Errorf("line %q", line) } // A composition failing after them is counted in the last entry (the operator, 2026-11-02). - withComposition(v, meshwants.CompositionsByGroupHeadsValue{State: meshdeliverycheckerclient.CompositionFail, Machine: "g14", Words: "route-proxy is defined twice"}) - if n := failedCount(*v); n != 61 || v.Failed.Test != "integration: x/p0.TestT" { + refused := refusedOn("x", "g14", "route-proxy is defined twice") + rules.JudgeComposition(v, rules.Composition{Composed: &refused}) + if n := rules.FailedCount(*v); n != 61 || v.Failed.Test != "integration: x/p0.TestT" { t.Errorf("after the composition failedCount = %d, failed %+v", n, v.Failed) } diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index 836d2a6e..0db44318 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -1,12 +1,15 @@ package check import ( + "cmp" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" + "io/fs" + "mesh-delivery-checker/rules" "os" "path/filepath" "reflect" @@ -15,6 +18,7 @@ import ( "strings" "sync" "testing" + "testing/fstest" "time" "unicode/utf8" @@ -25,7 +29,6 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/sdk/buildstep" @@ -132,6 +135,69 @@ type bench struct { // declines is how many asks naming needs a node lacking them declines before shanks, which has every need, takes // one (issue 890); an ask naming none runs on shanks, which then has none. declines int + // inputs are the checking program's kept inputs; heads the heads laid for it (none when nil); run, when set, answers + // a run ask in its place (a program that does not build or answer). + inputs *memKV + heads fs.FS + run func(a buildstep.StepAsk) (buildstep.Step, bool) + mu sync.Mutex + runs []buildstep.StepAsk // the run asks put +} + +// benchAsks puts the bench's asks: a run ask is answered at once by the checking program's work (answerRun), written by +// write and kept apart (bench.runs); every other ask goes to inner (the fake bus by default), answered as it answers. +type benchAsks struct { + b *bench + inner buildrun.AskPublisher + write func(buildstep.Step) +} + +func (b *bench) asker(inner buildrun.AskPublisher, write func(buildstep.Step)) benchAsks { + return benchAsks{b: b, inner: inner, write: write} +} + +func (p benchAsks) PublishAsk(ctx context.Context, subject, id string, data []byte) error { + inner, write := p.inner, p.write + if inner == nil { + inner, write = p.b.asks, p.b.asks.Write + } + a, err := buildstep.DecodeStepAsk(data) + if err != nil || a.Run == nil { + return inner.PublishAsk(ctx, subject, id, data) + } + p.b.mu.Lock() + p.b.runs = append(p.b.runs, a) + p.b.mu.Unlock() + if step := p.b.answerRun(a); step.ID != "" { + write(step) + } + return nil +} + +// trunkD is mesh-delivery's trunk the bench's checking program is built at. +var trunkD = strings.Repeat("d", 40) + +// answerRun answers a run ask as the build module would: the checking program's work (rules.Program) over the input the +// ask names, read from the kept inputs and checked against its digest. +func (b *bench) answerRun(a buildstep.StepAsk) buildstep.Step { + began := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC) + step := buildstep.Step{ID: a.ID, Tool: a.Kind + " ask", Node: "shanks", State: buildstep.StepPassed, Started: began, Ended: began.Add(time.Second)} + if b.run != nil { + if s, ok := b.run(a); ok { + return s + } + } + answer := buildstep.RunAnswer{Built: true, Key: "program-0123456789abcdef"} + out, err := rules.Program(a.Run.Args, func() ([]byte, error) { + body, _, _, _ := b.inputs.Get(context.Background(), a.Run.Input.Digest) + return body, a.Run.Input.Verify(body) + }, b.heads) + if err != nil { + answer.Exit, answer.Said = 2, err.Error() + } + answer.Output = out + step.Result, _ = json.Marshal(answer) + return step } // refsOf reads the siblings of a fetch's arguments, as they arrive in an ask (JSON) or as the checker gave them. @@ -190,7 +256,7 @@ func (b *bench) answer(a buildstep.StepAsk) buildstep.Step { // newBench is an examiner over fake build modules that merge cleanly, digest the captured fixture's packages and pass // the captured slow fixture's result at both levels, on shanks. func newBench(t testing.TB) *bench { - b := &bench{steps: &fakeSteps{}, kept: newMemKV(), prev: newMemKV()} + b := &bench{steps: &fakeSteps{}, kept: newMemKV(), prev: newMemKV(), inputs: newMemKV(), heads: fstest.MapFS{}} b.fetch = captured[Fetched](t, "go-fetch-clean") b.fetch.Modules, b.fetch.Changed, b.fetch.Trees = []string{"."}, []string{"README.md"}, nil one := captured[Digested](t, "go-digest") @@ -214,8 +280,8 @@ func newBench(t testing.TB) *bench { } now := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC) b.asks = &buildrun.FakeAsks{Answer: b.answer} - b.ex = &Examiner{Asks: buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 5 * time.Second}, Toolchains: goEntries("shanks"), Kept: Kept{Store: b.kept}, Previews: b.prev, - Modules: langs{}, Trunks: &Trunks{}, Now: func() time.Time { now = now.Add(50 * time.Millisecond); return now }} + b.ex = &Examiner{Asks: buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 5 * time.Second}, Toolchains: goEntries("shanks"), Kept: Kept{Store: b.kept}, Previews: b.prev, + Modules: langs{}, Trunks: &Trunks{heads: map[string]string{DeliveryRepository: trunkD}}, Inputs: memInputs{b.inputs}, Now: func() time.Time { now = now.Add(50 * time.Millisecond); return now }} return b } @@ -306,6 +372,81 @@ func TestACleanMergeIsFetchedAtItsTrunkAndBothLevelsPass(t *testing.T) { // 1679 t.Errorf("the check ask carries the names %v", asks) } } + + // Issue 1921 (B0, D4 to D6): the check's verdict is the checking program's, built at the group's mesh-delivery head, + // else at mesh-delivery's trunk, over the facts kept as its input; a program that is never begun, does not build or + // does not answer fails the check naming it, and a rule the change's program alters judges the check. + verdictOf := func(state, words string) json.RawMessage { + out, _ := json.Marshal(rules.Answer{V: rules.AnswerVersion, Verdict: meshdeliverychecker.VerdictsByGroupHeadsValue{State: state, Failed: &FailedFirst{Words: words}}}) + return out + } + for _, row := range []struct { + name string + repo string // the member's repository: mesh-delivery unless named + run func(a buildstep.StepAsk, s buildstep.Step) (buildstep.Step, bool) + at string // the commit the program is built at; empty when it fails + fails string + runs int // the run asks put when it fails: each put twice when never begun, and none after the seat list's + }{ + {name: "at the group's mesh-delivery head", at: head1}, + {name: "at mesh-delivery's trunk", repo: "novox/mesh-catalog", at: trunkD}, + {name: "never begun", run: func(buildstep.StepAsk, buildstep.Step) (buildstep.Step, bool) { return buildstep.Step{}, true }, + fails: "no build node began the job run of the asks", runs: 2}, + {name: "does not build", run: func(_ buildstep.StepAsk, s buildstep.Step) (buildstep.Step, bool) { + s.State, s.Reason = buildstep.StepFailed, "the program does not build: undefined: rules.Nothing" + return s, true + }, fails: "by the checking program: its run ask", runs: 1}, + {name: "a rule the change alters", at: head1, run: func(a buildstep.StepAsk, s buildstep.Step) (buildstep.Step, bool) { + if a.Run.Args[0] != "-verdict" { + return s, false + } + s.Result, _ = json.Marshal(buildstep.RunAnswer{Key: "program-0123456789abcdef", Output: verdictOf(meshdeliverycheckerclient.VerdictFail, "the change's own rule refuses it")}) + return s, true + }, fails: "the change's own rule refuses it"}, + } { + b := newBench(t) + b.ex.BeginBound = 20 * time.Millisecond + if row.run != nil { + b.run = func(a buildstep.StepAsk) (buildstep.Step, bool) { + return row.run(a, buildstep.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: buildstep.StepPassed}) + } + } + repo := cmp.Or(row.repo, repoA) + v := b.judge(t, caseOf(t, pullOf(repo, 7, head1))).Verdict + if row.fails != "" { + if v.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(v.Failed.Words, row.fails) { + t.Errorf("%s: verdict %+v, want a fail saying %q", row.name, v, row.fails) + } + if row.at == "" && (len(b.runs) != row.runs || v.JudgedBy != nil || slices.ContainsFunc(b.runs, func(a buildstep.StepAsk) bool { return a.Run.Args[0] == "-verdict" })) { + t.Errorf("%s: %d run asks, judged by %+v: want %d and none judging", row.name, len(b.runs), v.JudgedBy, row.runs) + } + } else if v.State != meshdeliverycheckerclient.VerdictPass { + t.Errorf("%s: verdict %+v", row.name, v) + } + if row.at == "" { + continue + } + if by := v.JudgedBy; by == nil || by.Program.Commit != row.at || by.Program.Package != CheckingProgramPackage || by.Key == "" || + len(by.Heads) != 1 || by.Heads[0].Repository != repo || by.Heads[0].Commit != head1 { + t.Errorf("%s: judged by %+v, want the program at %s over the member's head", row.name, v.JudgedBy, row.at) + } + if len(b.runs) != 2 || b.runs[0].Run.Args[0] != "-seats" || b.runs[1].Run.Args[0] != "-verdict" { + t.Fatalf("%s: run asks %+v, want the seat list and then the verdict", row.name, b.runs) + } + in := b.runs[1].Run.Input // the facts kept as their exact bytes under their digest + body, _, found, _ := b.inputs.Get(context.Background(), in.Digest) + if !found || in.Verify(body) != nil || in.Verb != InputVerb || + !strings.Contains(string(body), `"group":"feat/x"`) { + t.Errorf("%s: the verdict's input %+v is not kept whole: %s", row.name, in, body) + } + // Review round 1 of milestone 47/B0: a level is carried as the rules read it, without its digest, its packages or + // its kept packages' names, so a group's facts stay within the input's bound. + for _, unread := range []string{`"digest":{`, `"packages":[`, `"unmet"`, `"package":"m/`} { + if strings.Contains(string(body), unread) { + t.Errorf("%s: the facts carry %s, which no rule reads: %s", row.name, unread, body) + } + } + } } // namesOf is the nodes' names as a test gives them. @@ -452,8 +593,7 @@ func TestANodePackageIsNeverKeptAndRunsEveryCheck(t *testing.T) { // 1288/AC4, 1 func TestEveryFailureOfEveryMemberLevelAndTheCompositionIsNamed(t *testing.T) { for _, conflicts := range []bool{false, true} { b := newBench(t) - comps := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.CompositionFail, Sdk: "x", - Machine: "g14", Words: "route-proxy is defined twice"}, waits: conflicts} + comps := &fakeCompositions{v: refusedOn("x", "g14", "route-proxy is defined twice"), waits: conflicts} b.ex.Compositions = comps b.fetch.Changed = []string{"modules/m/internal/b/b_test.go"} b.digest[buildstep.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"m/internal/a": digA, "m/internal/b": digB}} @@ -487,7 +627,7 @@ func TestEveryFailureOfEveryMemberLevelAndTheCompositionIsNamed(t *testing.T) { got = append(got, firstFailed(meshdeliverychecker.VerdictsByGroupHeadsValue{Failed: &FailedFirst{Test: f.Test, Machine: f.Machine, Words: f.Words}})) } tests := []string{"unit: fixture/failing/bad.TestThreeIsFour", "integration: fixture/failing/bad.TestThreeIsFour"} - want := append(append(slices.Clone(tests), tests...), "g14: route-proxy is defined twice") // both members fail their tests + want := append(append(slices.Clone(tests), tests...), "g14: not composed on g14: route-proxy is defined twice") // both members fail their tests if conflicts { want = append([]string{"shanks: novox/mesh-sdk#12 does not merge into the trunk: files in conflict: a.go"}, tests...) } @@ -557,6 +697,10 @@ func TestAHeadThatDoesNotMergeEndsFailedNamingTheFilesAndRunsNoTest(t *testing.T if n := len(b.steps.tool("digest")) + len(b.steps.tool("test")); n != 0 { t.Errorf("%d digest and test steps ran after a conflict", n) } + // 1921/D1c: the node that ran the step is a fact, so the verdict judged from the facts names its machine. + if m := out.Verdict.Machines; len(m) != 1 || m[0] != "shanks" { + t.Errorf("machines = %v, want the node that ran the step", m) + } } func TestALevelWhoseDigestIsNotNamedRunsWholeAndKeepsNothing(t *testing.T) { // 1287/AC4 @@ -927,7 +1071,7 @@ func TestAnAskWhoseStepNeverEndsEndsTheCheckLoudly(t *testing.T) { b.asks.Answer = func(a buildstep.StepAsk) buildstep.Step { return buildstep.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: buildstep.StepRunning, Started: time.Now()} } - b.ex.Asks = buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 20 * time.Millisecond} + b.ex.Asks = buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond} out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) if f := out.Verdict.Failed; out.Verdict.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(f.Words, "on shanks did not end within") { t.Fatalf("verdict = %+v", out.Verdict) @@ -967,7 +1111,7 @@ func TestACheckAskWaitingInItsStreamIsNotBounded(t *testing.T) { b := newBench(t) answer := b.asks.Answer b.asks.Answer = nil - b.ex.Asks = buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 20 * time.Millisecond} + b.ex.Asks = buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond} done := make(chan Outcome, 1) go func() { done <- b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) }() for deadline := time.Now().Add(10 * time.Second); len(b.asks.Asks()) == 0; time.Sleep(time.Millisecond) { @@ -1076,6 +1220,9 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { // (ADR 0636 §4, ADR 0633 §6); "outside" is mesh-contracts, whose definition of another domain's seat is not taken // (ADR 0636 §5); "misnamed" is the domain's module whose seats/mesh-catalog/seat.json names another seat. heads string + // headSeats lays heads that add a seat: the checking program's seat list is carried by every judge ask's request (ADR + // 0636 §4, issue 1921 D4). + headSeats bool }{ {name: "the seat list read is the request's Seats", seats: "read"}, {name: "the members' heads' definitions over the list's, for the trunk's manifest validator too", seats: "read", indexer: "ok", heads: "domain"}, @@ -1099,6 +1246,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { {name: "a package series kept", series: "0.1"}, {name: "a package new in the group", series: "0.1", known: []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/web/go"}}, {name: "an answered packages state holding no name", emptyState: true, known: []string{}}, + {name: "the heads' seat list is carried by the head's judge and the trunk's", headSeats: true, indexer: "ok", ownJudge: true}, {name: "accepted in a group try", try: true}, {name: "refused in a group try", try: true, refused: "web has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web has no id`}, {name: "a cycle the group try makes", try: true, cycle: "provisions", fails: "the change's module graph has the cycle mail -> web -> mail"}, @@ -1119,6 +1267,12 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { {name: "refused", refused: "web requires \"amqp\"\nweb has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web requires "amqp"; web has no id`}, } { b := newBench(t) + if row.headSeats { + b.heads = fstest.MapFS{ + repoA + "/modules/x-contracts/module.json": {Data: []byte(`{"module": "x-contracts", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`)}, + repoA + "/modules/x-contracts/seats/node-x/seat.json": {Data: []byte(`{"format":1,"seat":"node-x","scope":"node","grain":"whole","domain":"Delivery","decision":"ADR 1"}`)}, + } + } b.ex.Manifests = fixedDigests{"mail": strings.Repeat("a", 64), "web": strings.Repeat("b", 64)} b.ex.Trunks = fixedTrunks{heads: map[string]string{DeliveryRepository: trunk}} b.ex.Predefined = keptList(`{"font-size": {"type": "number", "description": "the size"}}`) @@ -1233,13 +1387,14 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { } dir := "modules/" + contracts p.Modules = append(p.Modules, contracts) - b.ex.Forge = headFiles{ - repoA + " " + dir + "/module.json " + head1: `{"module": "` + contracts + `", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`, - repoA + " " + dir + "/seats/mesh-catalog/seat.json " + head1: `{"format":1,"seat":"` + seat + `","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1","includes":["lists"],` + - `"verbs":[{"name":"modules","input":{"type":"object"},"answer":{"type":"object"}}]}`, - repoA + " " + dir + "/parts/lists.json " + head1: `{"format":1,"part":"lists","verbs":[{"name":"seat-definitions","input":{"type":"object"},"answer":{"type":"object"}}]}`, + // The checking program reads the heads the build node lays for its run (D6): the running checker reads none. + b.heads = fstest.MapFS{ + repoA + "/" + dir + "/module.json": {Data: []byte(`{"module": "` + contracts + `", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`)}, + repoA + "/" + dir + "/seats/mesh-catalog/seat.json": {Data: []byte(`{"format":1,"seat":"` + seat + `","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1","includes":["lists"],` + + `"verbs":[{"name":"modules","input":{"type":"object"},"answer":{"type":"object"}}]}`)}, + repoA + "/" + dir + "/seats/mesh-catalog/fixtures/a.json": {Data: []byte(`{}`)}, + repoA + "/" + dir + "/parts/lists.json": {Data: []byte(`{"format":1,"part":"lists","verbs":[{"name":"seat-definitions","input":{"type":"object"},"answer":{"type":"object"}}]}`)}, } - b.ex.Trees = headTrees{repoA + " " + dir + "/seats " + head1: {"mesh-catalog/seat.json": "1", "mesh-catalog/fixtures/a.json": "2"}} } p.Fps = map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"web": {Fp2: "src2:" + strings.Repeat("f", 64), Manifest: web}} // Issue 1921: the judge is built at the group's heads too, also a member that publishes no package yet. @@ -1304,6 +1459,13 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { judged[0].Package != JudgePackage || judged[0].Dir != JudgeDir { t.Fatalf("%s: judged %+v (verdict %+v)", row.name, judged, v) } + for _, j := range judged { + var seated composer.Request + if err := json.Unmarshal(j.Request, &seated); err != nil || (len(seated.Seats) == 1) != row.headSeats || + (row.headSeats && !strings.Contains(string(seated.Seats[0]), `"node-x"`)) { + t.Errorf("%s: the judge at %v is handed the seats %s, want the heads' list: %v", row.name, j.Fetch.Commit, seated.Seats, err) + } + } var req composer.Request if err := json.Unmarshal(judged[0].Request, &req); err != nil || req.Cases[0].Manifests["web"].Digest != web || req.Cases[1].Name != JudgeTrunk || req.Cases[1].Manifests["web"].Digest != strings.Repeat("b", 64) || @@ -1336,7 +1498,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { } else if row.seats == "read" && (len(req.Seats) != 2 || string(req.Seats[0]) != `{"seat":"a"}` || string(req.Seats[1]) != `{"seat":"b"}`) { t.Errorf("%s: the request's seats %s, want the two definitions the reader holds", row.name, req.Seats) } - if row.seats == "" && len(req.Seats) != 0 { + if row.seats == "" && !row.headSeats && len(req.Seats) != 0 { t.Errorf("%s: the request's seats %s, want none from a checker that follows no list", row.name, req.Seats) } if !reflect.DeepEqual(req.Packages, row.known) || len(req.Input) != 0 && string(req.Input) != "null" { diff --git a/modules/mesh-delivery-checker/internal/check/failure_words_test.go b/modules/mesh-delivery-checker/internal/check/failure_words_test.go index 5358d7a2..d43c905d 100644 --- a/modules/mesh-delivery-checker/internal/check/failure_words_test.go +++ b/modules/mesh-delivery-checker/internal/check/failure_words_test.go @@ -2,6 +2,7 @@ package check import ( "encoding/json" + "mesh-delivery-checker/rules" "strings" "testing" @@ -20,81 +21,88 @@ func fatalAfterLog() Failure { } } -func TestAFailedTestsVerdictKeepsItsWholeFailureMessage(t *testing.T) { // 1901/AC1 - v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}}) - for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} { - if !strings.Contains(v.Failed.Words, want) { - t.Errorf("words lack %q: %q", want, v.Failed.Words) - } - } - if len(v.Failed.Words) > MaxWords+200 { - t.Errorf("words are %d bytes, bound %d", len(v.Failed.Words), MaxWords) - } -} - -func TestTheStatusLineNamesTheFailureNotTheFirstLoggedLine(t *testing.T) { // 1901/AC2 - v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}}) - got := firstFailed(*v) - if !strings.Contains(got, "3 subscription(s) derive to a subject no emitter owns:") || strings.Contains(got, "judged against") || strings.Contains(got, "\n") { - t.Errorf("firstFailed = %q", got) - } - // With no cause told apart (an older build module), the words still start at the first line, as before. - f := fatalAfterLog() - f.Cause = "" - v = levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) - if !strings.HasPrefix(afterHid(v.Failed.Words), "agreement_catalogue_test.go:130") { - t.Errorf("words = %q", v.Failed.Words) - } -} - -func TestWordsLongerThanTheBoundKeepTheirLastLines(t *testing.T) { // 1901/AC1 - f := Failure{Output: strings.Repeat("log line of some length\n", 500) + "the cause is here"} - v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) - if !strings.HasSuffix(v.Failed.Words, "the cause is here") || len(v.Failed.Words) > MaxWords+200 { - t.Errorf("%d bytes, ends %q", len(v.Failed.Words), v.Failed.Words[len(v.Failed.Words)-30:]) - } - _ = meshdeliverycheckerclient.VerdictFail -} - -// An older build module sends no "cause": its answer is still accepted and the verdict's words are its output, as before -// (1901/AC3). A failure that hides its cause (none, or a passing line) is said loudly first in the words and in the status -// line, naming the build module and the version it says it is, and the level still fails; a failure that keeps the -// contract is worded as before (1901/AC8). -func TestAnOlderBuildModulesFailureIsAcceptedWithItsWholeOutput(t *testing.T) { // 1901/AC3, AC8 - old := `{"package":"p","test":"TestX","output":"x_test.go:1: logged\nx_test.go:2: the old kept lines"}` - var f Failure - if err := json.Unmarshal([]byte(old), &f); err != nil || f.Cause != "" { - t.Fatalf("1901/AC3: err = %v, cause = %q", err, f.Cause) - } - v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}}) - if afterHid(v.Failed.Words) != "x_test.go:1: logged\nx_test.go:2: the old kept lines" { - t.Errorf("1901/AC3: words = %q", v.Failed.Words) - } - if got := firstFailed(*v); !strings.Contains(got, "the build module hid the failure") || strings.Contains(got, "\n") { - t.Errorf("1901/AC3, AC8: status = %q", got) - } - - by := "mesh-build-node with mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78" - for _, c := range []struct { - name, module, by, cause, output, loud string +// A failed test's words are its whole failure message, kept within their bound by their last lines (issue 1901). +func TestAFailuresWordsAreItsWholeMessageWithinTheirBound(t *testing.T) { + for _, row := range []struct { + name string + test func(t *testing.T) }{ - {"no cause", "mesh-build-node", by, "", "test/b.test.ts: something", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): it sent no cause"}, - {"a passing line as its cause", "mesh-build-node", by, "\u2714 the same merge has the same id (8.006913ms)", "", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): its cause is a passing line"}, - {"from before the contract", "mesh-build-node", "", "", "\u2714 the same merge has the same id (8.006913ms)", "the build module hid the failure (mesh-build-node at a version before the failure contract): it sent no cause"}, - {"a real cause", "mesh-build-node", by, "one plus one is not three", "not ok 3 - fails", ""}, + {"a failed tests verdict keeps its whole failure message", func(t *testing.T) { // 1901/AC1 + v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{fatalAfterLog()}}) + for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} { + if !strings.Contains(v.Failed.Words, want) { + t.Errorf("words lack %q: %q", want, v.Failed.Words) + } + } + if len(v.Failed.Words) > rules.MaxWords+200 { + t.Errorf("words are %d bytes, bound %d", len(v.Failed.Words), rules.MaxWords) + } + }}, + {"the status line names the failure not the first logged line", func(t *testing.T) { // 1901/AC2 + v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{fatalAfterLog()}}) + got := firstFailed(*v) + if !strings.Contains(got, "3 subscription(s) derive to a subject no emitter owns:") || strings.Contains(got, "judged against") || strings.Contains(got, "\n") { + t.Errorf("firstFailed = %q", got) + } + // With no cause told apart (an older build module), the words still start at the first line, as before. + f := fatalAfterLog() + f.Cause = "" + v = rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}}) + if !strings.HasPrefix(afterHid(v.Failed.Words), "agreement_catalogue_test.go:130") { + t.Errorf("words = %q", v.Failed.Words) + } + }}, + {"words longer than the bound keep their last lines", func(t *testing.T) { // 1901/AC1 + f := Failure{Output: strings.Repeat("log line of some length\n", 500) + "the cause is here"} + v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}}) + if !strings.HasSuffix(v.Failed.Words, "the cause is here") || len(v.Failed.Words) > rules.MaxWords+200 { + t.Errorf("%d bytes, ends %q", len(v.Failed.Words), v.Failed.Words[len(v.Failed.Words)-30:]) + } + _ = meshdeliverycheckerclient.VerdictFail + }}, + // An older build module sends no "cause": its answer is still accepted and the verdict's words are its output, as before + // (1901/AC3). A failure that hides its cause (none, or a passing line) is said loudly first in the words and in the status + // line, naming the build module and the version it says it is, and the level still fails; a failure that keeps the + // contract is worded as before (1901/AC8). + {"an older build modules failure is accepted with its whole output", func(t *testing.T) { // 1901/AC3, AC8 + old := `{"package":"p","test":"TestX","output":"x_test.go:1: logged\nx_test.go:2: the old kept lines"}` + var f Failure + if err := json.Unmarshal([]byte(old), &f); err != nil || f.Cause != "" { + t.Fatalf("1901/AC3: err = %v, cause = %q", err, f.Cause) + } + v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}}) + if afterHid(v.Failed.Words) != "x_test.go:1: logged\nx_test.go:2: the old kept lines" { + t.Errorf("1901/AC3: words = %q", v.Failed.Words) + } + if got := firstFailed(*v); !strings.Contains(got, "the build module hid the failure") || strings.Contains(got, "\n") { + t.Errorf("1901/AC3, AC8: status = %q", got) + } + + by := "mesh-build-node with mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78" + for _, c := range []struct { + name, module, by, cause, output, loud string + }{ + {"no cause", "mesh-build-node", by, "", "test/b.test.ts: something", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): it sent no cause"}, + {"a passing line as its cause", "mesh-build-node", by, "\u2714 the same merge has the same id (8.006913ms)", "", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): its cause is a passing line"}, + {"from before the contract", "mesh-build-node", "", "", "\u2714 the same merge has the same id (8.006913ms)", "the build module hid the failure (mesh-build-node at a version before the failure contract): it sent no cause"}, + {"a real cause", "mesh-build-node", by, "one plus one is not three", "not ok 3 - fails", ""}, + } { + t.Run(c.name, func(t *testing.T) { + v := rules.LevelFailure("", c.module, "unit", Tested{By: c.by, Failed: []Failure{{Package: "modules/gitea", Test: "fails", Cause: c.cause, Output: c.output}}}) + if v.State != meshdeliverycheckerclient.VerdictFail { + t.Errorf("1901/AC8: the verdict is %q, want it failed", v.State) + } + line := firstFailed(*v) + switch { + case c.loud == "" && strings.Contains(v.Failed.Words, "hid the failure"): + t.Errorf("1901/AC8: a failure that keeps the contract is said hidden: %q", v.Failed.Words) + case c.loud != "" && (!strings.HasPrefix(v.Failed.Words, c.loud) || !strings.Contains(line, c.loud) || !strings.Contains(v.Failures[0].Words, "hid the failure")): + t.Errorf("1901/AC8: words %q, status %q, want both to start with %q", v.Failed.Words, line, c.loud) + } + }) + } + }}, } { - t.Run(c.name, func(t *testing.T) { - v := levelFailure(&target{module: c.module}, "unit", Tested{By: c.by, Failed: []Failure{{Package: "modules/gitea", Test: "fails", Cause: c.cause, Output: c.output}}}) - if v.State != meshdeliverycheckerclient.VerdictFail { - t.Errorf("1901/AC8: the verdict is %q, want it failed", v.State) - } - line := firstFailed(*v) - switch { - case c.loud == "" && strings.Contains(v.Failed.Words, "hid the failure"): - t.Errorf("1901/AC8: a failure that keeps the contract is said hidden: %q", v.Failed.Words) - case c.loud != "" && (!strings.HasPrefix(v.Failed.Words, c.loud) || !strings.Contains(line, c.loud) || !strings.Contains(v.Failures[0].Words, "hid the failure")): - t.Errorf("1901/AC8: words %q, status %q, want both to start with %q", v.Failed.Words, line, c.loud) - } - }) + t.Run(row.name, row.test) } } diff --git a/modules/mesh-delivery-checker/internal/check/fakes_test.go b/modules/mesh-delivery-checker/internal/check/fakes_test.go index d53f2a14..bade10ab 100644 --- a/modules/mesh-delivery-checker/internal/check/fakes_test.go +++ b/modules/mesh-delivery-checker/internal/check/fakes_test.go @@ -68,3 +68,14 @@ func (m *memKV) Delete(_ context.Context, key string) error { delete(m.revs, key) return nil } + +// memInputs keeps run inputs in a memKV, under their digest. +type memInputs struct{ kv *memKV } + +func (m memInputs) Keep(ctx context.Context, digest string, body []byte) error { + if _, _, found, _ := m.kv.Get(ctx, digest); found { + return nil + } + _, err := m.kv.Create(ctx, digest, body) + return err +} diff --git a/modules/mesh-delivery-checker/internal/check/graph_cycle.go b/modules/mesh-delivery-checker/internal/check/graph_cycle.go deleted file mode 100644 index 6dc34556..00000000 --- a/modules/mesh-delivery-checker/internal/check/graph_cycle.go +++ /dev/null @@ -1,76 +0,0 @@ -package check - -import ( - "context" - "fmt" - "maps" - "slices" - "strings" - - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" - "git.novox.be/novox/mesh-delivery/sdk/composer" - "git.novox.be/novox/mesh-sdk/go/catalogue" - - "git.novox.be/novox/mesh-delivery/sdk/graph" -) - -// The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each -// wait for the other for good, through their bases, the provisions and bus state they await, or the verb edges the graph -// keeps (ADR 0614 §6). A verb edge on a cycle is left out of the graph, so it is never refused (§3), and a package or a -// build module's edge is a trigger, never a wait, so a cycle through one is not refused either (§6). The graph is worked out from the -// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's), -// with the contract's ManifestGraph, the way the indexer works out its graph's edges. A cycle the trunk already has among -// the same modules is the trunk's: said in the verdict's notes, not the change's failure. - -// ManifestReader reads a kept manifest by its digest (the indexer's manifests state). -type ManifestReader interface { - Manifest(ctx context.Context, digest string) (catalogue.Manifest, error) -} - -// refuseCycle fails the check for a cycle of the change's module graph, naming it; nil when the case touches no manifest, -// when nothing reads manifests, or when there is none. A manifest that cannot be read fails the check, said, never passes it. -func (st *state) refuseCycle(ctx context.Context) *meshdeliverychecker.VerdictsByGroupHeadsValue { - if st.j.Manifests == nil || st.j.Read == nil || st.narrowTry() { - return nil - } - trunk := st.j.Manifests.ManifestDigests() - req, touched := judgeRequest(trunk, st.c.Pulls) - if len(touched) == 0 { - return nil - } - read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) { - out := map[string]catalogue.Manifest{} - for _, module := range slices.Sorted(maps.Keys(digests)) { - m, err := st.j.Read.Manifest(ctx, digests[module].Digest) - if err != nil { - return nil, fmt.Errorf("the manifest %s of %s: %w", digests[module].Digest, module, err) - } - out[module] = m - } - return out, nil - } - change, err := read(req.Cases[0].Manifests) - if err != nil { - return fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error()) - } - cycle := graph.OfManifests(change).Cycle() - if cycle == nil { - st.c.note("the change's module graph has no cycle") - return nil - } - words := strings.Join(cycle, " -> ") - if before, err := read(req.Cases[1].Manifests); err == nil { - among := graph.Graph{} - for name, n := range graph.OfManifests(before) { - if slices.Contains(cycle, name) { - among[name] = n - } - } - if among.Cycle() != nil { - st.c.note("the module graph has the cycle " + words + ", which the trunk already has among the same modules (ADR 0603 §2)") - return nil - } - } - return fail(goModule, fmt.Sprintf("the change's module graph has the cycle %s: these modules would each wait for the other for "+ - "good, through their bases, the provisions and bus state they await, or the verbs they invoke (ADR 0603 §2, ADR 0614 §6)", words)) -} diff --git a/modules/mesh-delivery-checker/internal/check/graph_edges.go b/modules/mesh-delivery-checker/internal/check/graph_edges.go new file mode 100644 index 00000000..766ad6c0 --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/graph_edges.go @@ -0,0 +1,116 @@ +package check + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/sdk/composer" + "git.novox.be/novox/mesh-sdk/go/catalogue" + + "mesh-delivery-checker/rules" + + "git.novox.be/novox/mesh-delivery/sdk/graph" +) + +// The module graph of a change (novox/hq ADR 0603 §2, ADR 0614 §6): the running checker gathers its ordering edges, the +// checking program judges whether it has a cycle (rules.JudgeGraph, ADR 0656 §5). The graph is worked out from the +// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's) +// and as the trunk has it, with the contract's ManifestGraph, the way the indexer works out its graph's edges; the facts +// carry only each edge's two modules and its kind. + +// ManifestReader reads a kept manifest by its digest (the indexer's manifests state). +type ManifestReader interface { + Manifest(ctx context.Context, digest string) (catalogue.Manifest, error) +} + +// gatherGraph records the change's and the trunk's module graph edges as a fact; none when the case touches no manifest +// or nothing reads manifests. A change's manifest that cannot be read fails the check, said, never passes it; a trunk's +// that cannot be read leaves the trunk's graph unread, so no cycle is the trunk's. +func (st *state) gatherGraph(ctx context.Context) { + if st.j.Manifests == nil || st.j.Read == nil || st.narrowTry() { + return + } + req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.c.Pulls) + if len(touched) == 0 { + return + } + read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) { + out := map[string]catalogue.Manifest{} + for _, module := range slices.Sorted(maps.Keys(digests)) { + m, err := st.j.Read.Manifest(ctx, digests[module].Digest) + if err != nil { + return nil, fmt.Errorf("the manifest %s of %s: %w", digests[module].Digest, module, err) + } + out[module] = m + } + return out, nil + } + change, err := read(req.Cases[0].Manifests) + if err != nil { + st.fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error()) + return + } + g := rules.Graph{} + changed := orderingEdges(graph.OfManifests(change)) + trunk := map[[2]string]string{} + if before, err := read(req.Cases[1].Manifests); err == nil { + g.Trunk = true + trunk = orderingEdges(graph.OfManifests(before)) + } + for _, pair := range slices.SortedFunc(maps.Keys(changed), cmpPair) { + e := rules.Edge{From: pair[0], To: pair[1], Kind: changed[pair]} + if _, both := trunk[pair]; !both && g.Trunk { + e.Only = rules.EdgeChange + } + g.Edges = append(g.Edges, e) + } + for _, pair := range slices.SortedFunc(maps.Keys(trunk), cmpPair) { + if _, both := changed[pair]; !both { + g.Edges = append(g.Edges, rules.Edge{From: pair[0], To: pair[1], Kind: trunk[pair], Only: rules.EdgeTrunk}) + } + } + st.facts = append(st.facts, rules.Step{Graph: &g}) +} + +func cmpPair(a, b [2]string) int { + return cmp.Or(strings.Compare(a[0], b[0]), strings.Compare(a[1], b[1])) +} + +// orderingEdges are the graph's ordering edges (graph.Graph.Before) between its modules, each pair once with the kind it +// is first met by: a base, a provision or bus state awaited of another provider, or a verb the graph keeps served by +// another module (a base on itself is a cycle). A verb edge the graph leaves out (ADR 0614 §3), a toolchain and a package edge are none (§6). +func orderingEdges(g graph.Graph) map[[2]string]string { + out := map[[2]string]string{} + add := func(from, to, kind string) { + if _, ours := g[to]; !ours || (from == to && kind != rules.EdgeBase) { + return + } + if _, met := out[[2]string{from, to}]; !met { + out[[2]string{from, to}] = kind + } + } + for _, name := range slices.Sorted(maps.Keys(g)) { + n := g[name] + for _, b := range n.Bases { + add(name, b, rules.EdgeBase) + } + for _, a := range n.Awaits { + for _, p := range g.Providers(a) { + add(name, p, rules.EdgeAwaits) + } + } + for _, v := range n.Invokes { + if slices.Contains(n.LeftOut, v) { + continue + } + for _, s := range g.Servers(v) { + add(name, s, rules.EdgeInvokes) + } + } + } + return out +} diff --git a/modules/mesh-delivery-checker/internal/check/headseats.go b/modules/mesh-delivery-checker/internal/check/headseats.go deleted file mode 100644 index 28d8c980..00000000 --- a/modules/mesh-delivery-checker/internal/check/headseats.go +++ /dev/null @@ -1,151 +0,0 @@ -package check - -// The seat definitions of the members' heads (novox/hq ADR 0636 §4, ADR 0633 §6 as its insight of 2026-11-05 (2) says): the -// manifest validator inside a check is the one reader that reads them. A manifest of a group may serve or invoke what the -// group's contracts modules add to a seat (a verb, an event), which the seat list the checker follows (the trunks') does not -// hold yet; judged without them, by the trunk's manifest validator above all, the group's new indexer would be refused for -// serving its own seat's new verb, and so would every module that calls it. The definitions are read from each member's head -// with the indexer's own reading (the domain SDK's seatdefs: the contracts module's `seats//seat.json` with the parts it -// includes, and the rule which module may define a seat), through the forge, and put over the list's in the request's Seats. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "maps" - "slices" - - "git.novox.be/novox/mesh-delivery/sdk/seatdefs" - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Trees lists the files of a repository under directories at a commit (the forge's commit trees): {directory: {path -// relative to it: blob id}}. A directory the commit lacks is an error wrapping ErrNotFound. -type Trees interface { - Files(ctx context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) -} - -// headSeats are the definitions, resolved, that the contracts modules the case's members touch hold at their heads, by -// seat; none where the checker lists no trees. They are read and ruled on as the indexer reads and rules on a revision's, -// with the domain SDK's one reading (mesh-delivery sdk/seatdefs): a seat of the mesh's own contracts is taken only from -// its domain's contracts module (ADR 0636 §5), so an outside definer's is left out and said, and a seat two touched modules -// define is refused unless one hands it over to its domain's module. A head's definition that cannot be read, does not -// parse or names another seat than its directory's is an error: the manifests cannot be judged by the group's seats then. -func (st *state) headSeats(ctx context.Context) (map[string]json.RawMessage, error) { - if st.j.Trees == nil || st.j.Forge == nil { - return nil, nil - } - out := map[string]json.RawMessage{} - by := map[string]string{} // seat -> the module whose head's definition is taken - for _, p := range st.c.Pulls { - for _, module := range p.Modules { - c, ok, err := st.contractsModule(ctx, p, module) - if err != nil { - return nil, err - } - if !ok { - continue - } - defs, unparsed, err := st.headForge().Read(ctx, p.Repository, p.Head, c) - if err != nil { - return nil, fmt.Errorf("the seat definitions of %s at the head of %s cannot be read%s: %w", module, pullKey(p), unheardWords(err), err) - } - if len(unparsed) > 0 { - seat := slices.Sorted(maps.Keys(unparsed))[0] - return nil, fmt.Errorf("the seat definition of %s (module %s) at the head of %s is not taken: %s", seat, module, pullKey(p), unparsed[seat]) - } - for _, seat := range slices.Sorted(maps.Keys(defs)) { - d := defs[seat] - if why := seatdefs.Refusal(c.Module, d); why != "" { - st.c.note(fmt.Sprintf("the head of %s's definition of %s is not taken: %s", pullKey(p), seat, why)) - continue - } - if prev, twice := by[seat]; twice && prev != c.Module { - switch { - case seatdefs.HandsOver(prev, c.Module, d): - case seatdefs.HandsOver(c.Module, prev, d): - continue - default: - return nil, fmt.Errorf("the seat %s is defined at the heads by both %s and %s: a seat has one definition", seat, prev, c.Module) - } - } - raw, err := json.Marshal(d) - if err != nil { - return nil, fmt.Errorf("the definition of %s cannot be encoded: %w", seat, err) - } - out[seat], by[seat] = raw, c.Module - } - } - } - return out, nil -} - -// headForge is the domain SDK's reading bound to the checker's forge listing and get-file: a directory or a file the head -// does not hold is none, every other failure the forge's. -func (st *state) headForge() seatdefs.Forge { - return seatdefs.Forge{ - Files: func(ctx context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) { - got, err := st.j.Trees.Files(ctx, repository, commit, dirs) - if errors.Is(err, ErrNotFound) { - return nil, nil - } - return got, err - }, - File: func(ctx context.Context, repository, commit, file string) ([]byte, error) { - text, err := st.j.Forge.File(ctx, repository, file, commit) - if errors.Is(err, ErrNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return []byte(text), nil - }, - } -} - -// contractsModule is a touched module as a contracts module at its member's head (its package artifact's generated-from, -// under the module's directory), and whether the module is a contracts module at all. -func (st *state) contractsModule(ctx context.Context, p Pull, module string) (seatdefs.Module, bool, error) { - var m catalogue.Manifest - dir, known := "", false - if st.j.Dirs != nil { - dir, known = st.j.Dirs.ModuleDir(module) - } - if digest := p.Fps[module].Manifest; known && digest != "" && st.j.Read != nil { - var err error - if m, err = st.j.Read.Manifest(ctx, digest); err != nil { - return seatdefs.Module{}, false, fmt.Errorf("the manifest of %s at the head of %s cannot be read: %w", module, pullKey(p), err) - } - } else { - var found bool - var err error - if m, dir, found, err = st.manifestAtHead(ctx, p, module); err != nil || !found { - return seatdefs.Module{}, false, err - } - } - c, ok := seatdefs.Of(dir, m) - return c, ok, nil -} - -// overSeats is the list's definitions with each of the heads' put in place of the one of its seat, or added, sorted by seat. -func overSeats(list []json.RawMessage, heads map[string]json.RawMessage) []json.RawMessage { - if len(heads) == 0 { - return list - } - by := map[string]json.RawMessage{} - for _, raw := range list { - var d struct { - Seat string `json:"seat"` - } - _ = json.Unmarshal(raw, &d) - by[d.Seat] = raw - } - maps.Copy(by, heads) - out := make([]json.RawMessage, 0, len(by)) - for _, seat := range slices.Sorted(maps.Keys(by)) { - out = append(out, by[seat]) - } - return out -} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index 10680a69..8afd1362 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "path" + "mesh-delivery-checker/rules" "slices" "sort" "strings" @@ -13,7 +13,6 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) @@ -50,11 +49,6 @@ const ( judgedByCandidate = "judged by the candidate's mesh-build-go at " ) -// verdictPtr is a verdict of failure a step of the check ends with. -type verdictPtr = *meshdeliverychecker.VerdictsByGroupHeadsValue - -func failed(machine, words string) verdictPtr { return fail(machine, words) } - // Asks puts a check's ask on its build seat's stream and waits for its step (buildrun.Asker; ADR 0525 §1, §6): the // checker chooses no node, starts no step and holds none; whichever build node pulls the ask runs the check. type Asks interface { @@ -140,16 +134,13 @@ type Examiner struct { Manifests TrunkManifests // SeatDefinitions reads the indexer's seat definitions of a pull request (seatdefs.go, design 26); nil reads none. SeatDefinitions SeatDefinitions - // SeatList gives the seat definitions the change's manifests are judged against, as the seat list reader holds them - // (manifests_judge.go, novox/hq ADR 0636 §1); nil gives the judge none. + // SeatList gives the seat list the checker's reader holds from the seat mesh-catalog (verb seat-definitions): the base the + // checking program lays the heads' definitions over (programs.go, novox/hq ADR 0636 §1, §4); nil gives it none. SeatList SeatList - // Trees lists a member's files at its head, so the seat definitions of the contracts modules a case touches are read - // at their heads and put over SeatList's (headseats.go, novox/hq ADR 0636 §4); nil reads none. - Trees Trees // Renames asks the seat mesh-config's verb renames for a change that brings or drops a `renamed-from` (renames.go, // design 32); nil fails such a change, saying it cannot ask. Renames Renames - // Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq + // Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_edges.go, novox/hq // ADR 0603 §2); nil works out no graph. Read ManifestReader // Serving says, from what runs, whether a running healthy build serves a seat verb or module tool (novox/hq ADR 0614 @@ -173,6 +164,10 @@ type Examiner struct { // and fingerprints a group try's manifest validator and composition judge (issue 1921); nil leaves a whole try with no // manifest or composition judged, said. Candidates TryCandidates + // Inputs keeps the checking program's inputs (the state InputsStateName, served on InputVerb); BeginBound is how long a run ask may wait for a + // build node to begin it (DefaultBeginBound when zero). + Inputs Inputs + BeginBound time.Duration Now func() time.Time Logf func(format string, args ...any) } @@ -183,11 +178,10 @@ type TryCandidates interface { TryCandidate(ctx context.Context, group, heads string, bound time.Duration) (meshcatalog.CandidatesByGroupValue, error) } -// CompositionReader makes a check's composition verdict; an error is only a case that cannot be composed at all. tagged -// are the group's tagged versions of its Go packages the check heard (design 51 *Tagged versions*), nil for none: a -// composer built at the node-engine member's head is built against them, as the check's other Go builds are. +// CompositionReader composes a check: the engines' answers, which the rules judge (rules.JudgeEngines); an error is only +// a case that cannot be composed at all. type CompositionReader interface { - Compose(ctx context.Context, c *Case, tagged buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error) + Compose(ctx context.Context, c *Case) (rules.Composed, error) } func (j *Examiner) now() time.Time { @@ -269,24 +263,18 @@ func (t *target) stepArgs(args map[string]any) map[string]any { return args } -// acc collects a level's counts across targets. -type acc struct { - run, kept, skipped int - elapsed, setup float64 - lint float64 - over bool - slowest []TestTime -} - // state is one judging. type state struct { j *Examiner c *Case targets []*target - levels map[string]*acc - nodes map[string]bool - clone time.Duration - slow []string + levels rules.Tally + // facts are the facts of the check met so far, in order: what the checking program judges (milestone 47/B0). + facts []rules.Step + // composition is how the check composed, recorded after every step (compose). + composition *rules.Composition + clone time.Duration + slow []string // beside are the siblings read at their trunk's head, by repository (ADR 0548 §6). beside map[string]string // judgeHead is the group's mesh-delivery member head when its merge changes the judge (ADR 0578 §2); empty judges @@ -304,9 +292,6 @@ type state struct { // noCandidate says why a whole try has no candidate of its group from the indexer, so its manifests were not judged and // it was not composed; empty when it has one, and for a pull request's check. noCandidate string - // seats is the check's seat list once read (checkSeats), and seatsErr why it is not. - seats *checkSeats - seatsErr error } // narrowTry says the case is a try narrowed to a level or to some tests: it judges no manifest, graph or composition. @@ -353,36 +338,46 @@ func (st *state) takeTryCandidate(ctx context.Context) { // Judge runs the case (it is the Judge of a Checker). A failure of the change, of a merge or of a step is a verdict; an error is only a case that cannot // be judged at all. func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) { - st := &state{j: j, c: c, levels: map[string]*acc{buildstep.LevelUnit: {}, buildstep.LevelIntegration: {}}, nodes: map[string]bool{}, + st := &state{j: j, c: c, levels: rules.NewTally(), beside: map[string]string{}} // Every member and level runs to its end, and every manifest the change touches is judged by the indexer's judge (ADR // 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02). - tests := st.judgeAll(ctx) + // Each failure is a fact recorded where it is met; the verdict is the judging rules' of the facts. + st.judgeAll(ctx) st.takeTryCandidate(ctx) - st.seriesChanges(ctx) // DLV-C4: named in the verdict's line, whatever the verdict - if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx), st.judgeRenames(ctx), st.refuseUnparsed(ctx))); v != nil { - return st.finish(ctx, *v), nil + st.seriesChanges(ctx) // DLV-C4: a fact of the check, named in the verdict whatever it is + // The heads' seat list, from the checking program, before the validator and the composer are asked (ADR 0636 §4). + // A list it cannot make ends the check there, failed naming the checking program, worded from the facts so far. + var answer rules.Answer + var by *meshdeliverychecker.VerdictsByGroupHeadsValueJudgedBy + if st.seatList(ctx) { + st.judgeManifests(ctx) + st.gatherGraph(ctx) + st.judgeRenames(ctx) + st.refuseUnparsed(ctx) + st.compose(ctx) + // The verdict is the checking program's of the facts (ADR 0644 §1, ADR 0656 §5). + answer, by = st.judged(ctx) + } else { + answer = rules.Answer{V: rules.AnswerVersion, Verdict: rules.Judge(st.factsOf())} } - return st.finish(ctx, meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictPass}), nil -} - -// finish is the outcome of the verdict with the state of the builds the candidate's modules carry and the assigner's -// composition of it, read also when the tests failed, so a failed composition is named beside them. -func (st *state) finish(ctx context.Context, v meshdeliverychecker.VerdictsByGroupHeadsValue) Outcome { - out := st.outcome(v) - st.composed(ctx, &out.Verdict) + for _, n := range answer.Notes { + st.c.note(n) + } + answer.Verdict.JudgedBy = by + out := st.outcome(answer.Verdict) out.Builds = st.j.buildNotes(ctx, st.c) - return out + return out, nil } -// composed makes the check's composition from the engines' answers into its verdict (ADR 0593 §7, §9): a failed -// composition fails the check, naming the machine it failed at; a node that did not answer is a warning. A try is not -// composed (it has no candidate); a composition that cannot be made leaves it not run, said. -func (st *state) composed(ctx context.Context, v *meshdeliverychecker.VerdictsByGroupHeadsValue) { +// compose records how the check composed as the facts' composition, after every step (ADR 0593 §7, §9): kept by a carry, +// not run (said why), or the engines' answers with the candidate's builds awaited first (ADR 0603 §10). A narrowed try +// and an examiner that composes nothing record none. +func (st *state) compose(ctx context.Context) { if st.c.Composition != nil && st.c.Try == nil { // A carry whose candidate's fingerprints did not change keeps the composition it passed (ADR 0548 §5). kept := *st.c.Composition - v.Composition = &kept + st.composition = &rules.Composition{Kept: &kept} st.c.note("the composition is kept: no fingerprint of a module it composed changed") return } @@ -390,7 +385,7 @@ func (st *state) composed(ctx context.Context, v *meshdeliverychecker.VerdictsBy return } if st.noCandidate != "" { - v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun, Words: st.noCandidate} + st.composition = &rules.Composition{NotRun: st.noCandidate} return } if st.j.Compositions == nil { @@ -398,78 +393,32 @@ func (st *state) composed(ctx context.Context, v *meshdeliverychecker.VerdictsBy } if len(st.unpreviewed) > 0 { // An engine would only say it cannot read a manifest the indexer will not have: the check does not ask it. - v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun, Words: "no preview of a merge of " + - strings.Join(st.unpreviewed, ", ") + " (its head does not merge or its fetch did not answer), so the change cannot be composed"} + st.composition = &rules.Composition{NotRun: "no preview of a merge of " + strings.Join(st.unpreviewed, ", ") + + " (its head does not merge or its fetch did not answer), so the change cannot be composed"} return } - words := st.awaitBuilds(ctx, v) // ADR 0603 §10: the check composes with the candidate's builds - // The composer of a node-engine member's head composes against the check's seat list, the judge's (ADR 0635 §1, ADR 0636 - // §4); only a list the reader read is handed: the heads' alone are no list, and the composer then judges no machine. - st.c.Seats = nil - seats, serr := st.checkSeats(ctx) - switch head := composerHead(st.c); { - case serr != nil && head != "": - // A composition at a node-engine member's head that cannot read the seat list it needs fails, naming why, rather than - // passing with no machine judged (ADR 0635 §3, ADR 0636 §1). - withComposition(v, meshwants.CompositionsByGroupHeadsValue{Group: st.c.Group, Heads: st.c.Heads, State: meshdeliverycheckerclient.CompositionFail, - Sdk: head, Words: "the composer at the node-engine's head " + head + " cannot be given the seat list it composes against: " + serr.Error()}) - return - case serr != nil: - st.c.note("the composition is given no seat list: " + serr.Error()) - case seats.listed: - st.c.Seats = seats.defs - } - comp, err := st.j.Compositions.Compose(ctx, st.c, st.tags.goPackages) + // ADR 0603 §10: the check composes with the candidate's builds; whether it failed already is the running rules' word + // on the facts so far, an asking choice only (the verdict is the rules' of every fact). + failed := rules.Judge(st.factsOf()).State == meshdeliverycheckerclient.VerdictFail + words, builds := st.awaitBuilds(ctx, failed) + c := &rules.Composition{Waits: words, BuildsFailed: builds, FPSet: st.c.FPSet} + st.composition = c + // A composer built at the node-engine member's head is built against the group's tagged versions the check heard, as + // its other Go builds are (design 51 *Tagged versions*); it composes against the heads' seat list (seatList). + st.c.Tagged = st.tags.goPackages + comp, err := st.j.Compositions.Compose(ctx, st.c) if err != nil { // Said in the verdict, never left as a composition not yet run (review finding 4). st.j.logf("%s: the composition could not be made, so it is said as not run: %v", st.c.Key, err) - v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun, Words: "not made: " + err.Error()} + c.NotRun = "not made: " + err.Error() return } - comp.Warnings = append(comp.Warnings, words...) // ADR 0603 §10 - withComposition(v, comp) - if v.Composition != nil && v.Composition.State != meshdeliverycheckerclient.CompositionNotRun { - v.Composition.Fpset = st.c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5) - } + c.Composed = &comp } -// outcome fills the verdict's levels, times, machines and composition from what ran. +// outcome is the verdict judged from the facts with the slowest tests, the siblings read and what judged a build module. func (st *state) outcome(v meshdeliverychecker.VerdictsByGroupHeadsValue) Outcome { - v.Series = st.c.allSeries() // the series changes, as structures (A2j finding 2) - if v.State == meshdeliverycheckerclient.VerdictPass || len(v.Levels) == 0 { - v.Levels = map[string]meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{} - for level, a := range st.levels { - v.Levels[level] = meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{Run: int64(a.run), Kept: int64(a.kept), Skipped: int64(a.skipped)} - } - } - ms := func(s float64) int64 { return int64(s * 1000) } - u, i := st.levels[buildstep.LevelUnit], st.levels[buildstep.LevelIntegration] - v.Took = &meshdeliverychecker.VerdictsByGroupHeadsValueTook{Clone: st.clone.Milliseconds(), Unit: ms(u.elapsed), Lint: ms(u.lint), IntegrationSetup: ms(i.setup), IntegrationRun: ms(i.elapsed)} - for node := range st.nodes { - v.Machines = append(v.Machines, node) - } - sort.Strings(v.Machines) - for _, level := range []string{buildstep.LevelUnit, buildstep.LevelIntegration} { - if st.levels[level].over { - v.OverBound = append(v.OverBound, level) - } - } - // The composition is made from the engines' answers (ADR 0593 §7): until it is, a verdict says it was not run (ADR 0484 clause 6). - v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun} - var slow []string - for _, level := range []string{buildstep.LevelUnit, buildstep.LevelIntegration} { - a := st.levels[level] - if !a.over { - continue - } - sort.SliceStable(a.slowest, func(x, y int) bool { return a.slowest[x].Elapsed > a.slowest[y].Elapsed }) - for n, s := range a.slowest { - if n == 3 { - break - } - slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed)) - } - } + slow := rules.Slowest(st.levels) out := Outcome{Verdict: v, Slowest: slow, OutOfStep: st.outOfStep} if len(st.beside) > 0 { out.Beside = st.beside @@ -499,12 +448,39 @@ func (st *state) stepOutOfStep(p Pull, files []string) { st.outOfStep[k] = slices.Compact(all) } -// fail is a verdict of failure at a machine. +// fail records a failure the asking met as a fact of the check, where it is met, and answers it as a verdict of failure +// at a machine: the verdict is the judging rules' of the facts (rules.Judge), so a failure only returned is no failure +// (milestone 47/B0). +func (st *state) fail(machine, words string) { + st.facts = append(st.facts, rules.Step{Failed: &FailedFirst{Machine: machine, Words: words}}) +} + +// fail is a verdict of failure at a machine, for a step that answers one before any member is asked (tagged); it is +// recorded as facts (failedAll), never returned as the check's verdict. func fail(machine, words string) *meshdeliverychecker.VerdictsByGroupHeadsValue { v := failure(machine, words) return &v } +// failedAll records every failure a verdict of the asking names (its list, else its first) as facts of the check. +func (st *state) failedAll(v meshdeliverychecker.VerdictsByGroupHeadsValue) { + all := failedList(v.Failures) + if len(all) == 0 { + all = []FailedFirst{failedOf(v)} + } + for _, f := range all { + st.facts = append(st.facts, rules.Step{Failed: &f}) + } +} + +// factsOf are the check's facts as met: what the checking program judges. +func (st *state) factsOf() rules.Facts { + f := st.factsHead() + f.Steps, f.CloneMS, f.Composition, f.Notes = st.facts, st.clone.Milliseconds(), st.composition, st.c.Notes() + f.Series = st.c.allSeries() + return f +} + // levels are the levels a case's asks run: both, or the one a try names. func (st *state) levelsOf() []string { if st.c.Try != nil && st.c.Try.Level != "" { @@ -517,7 +493,7 @@ func (st *state) levelsOf() []string { // judges each answer. A head that does not merge, a pin that is not allowed or a level that fails is a failure of that // member; every other member is still judged, and the verdict names every failure (the operator, 2026-11-02), unjoined // to its first lines (settled does that). Only a check whose context ended stops. -func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGroupHeadsValue { +func (st *state) judgeAll(ctx context.Context) { // 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) @@ -531,16 +507,18 @@ func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGr return 0 }) // A group's members are built only after each package they declare is published as the group's tagged version - // (design 51 *Tagged versions*): a package not published within the bound fails the check before any member is built. + // (design 51 *Tagged versions*): a package not published within the bound fails the check before any member is built, + // each failure a fact of the check. if v := st.tagged(ctx); v != nil { - return v + st.failedAll(*v) + return } var groupPackage *buildstep.FetchPackage - var failures []*meshdeliverychecker.VerdictsByGroupHeadsValue + met := len(st.facts) for _, p := range pulls { if ctx.Err() != nil { - if len(failures) == 0 { // an ask that ended with the context said it already - failures = append(failures, fail(CheckerModule, ctx.Err().Error())) + if !st.failedSince(met) { // an ask that ended with the context said it already + st.fail(CheckerModule, ctx.Err().Error()) } break } @@ -563,7 +541,7 @@ func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGr if bm == buildrun.GoBuildModule { beside, err := st.j.besideFor(ctx, st.c, p) if err != nil { - failures = append(failures, fail(forgeMachine(err, bm), err.Error()+unheardWords(err))) + st.fail(forgeMachine(err, bm), err.Error()+unheardWords(err)) continue } if len(beside) > 0 { @@ -577,9 +555,7 @@ func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGr } t.fetchArgs = args st.targets = append(st.targets, t) - if v := st.ask(ctx, t, &merged); v != nil { - failures = append(failures, v) - } + st.ask(ctx, t, &merged) } // Every build module's fetch merged (each is in merged once): the preview is written, also when the member's tests // failed, so the indexer holds its manifests and the assigner can compose the change (review of 650a1d0). @@ -598,7 +574,16 @@ func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGr groupPackage = &buildstep.FetchPackage{Repository: p.Repository, Commit: p.Head, Ref: refOf(p.Number)} } } - return joinFailures(failures...) +} + +// failedSince says a failure was recorded among the facts from the nth on. +func (st *state) failedSince(n int) bool { + for _, f := range st.facts[n:] { + if f.Failed != nil { + return true + } + } + return false } // DefaultDeclineBound is an Examiner's DeclineBound when it names none: longer than a check runs, so a node with the @@ -609,14 +594,14 @@ const DefaultDeclineBound = 30 * time.Minute // each level's digest, kept verdicts and tests. When the answer leaves packages unmet, // their tests needing what that node lacks (issue 890), the passes are kept and the levels from the first such one are // asked again of a node that has it (placed). -func (st *state) ask(ctx context.Context, t *target, merged *[]Fetched) *meshdeliverychecker.VerdictsByGroupHeadsValue { - return st.askNeeding(ctx, t, merged, st.levelsOf(), nil, nil) +func (st *state) ask(ctx context.Context, t *target, merged *[]Fetched) { + st.askNeeding(ctx, t, merged, st.levelsOf(), nil, nil) } // askNeeding is ask with the levels to run, what the node must have and the packages it tests: none for the first ask // of a target; for the placed one the unmet packages' needs and those packages alone, so no package the first ask judged // is run or counted again (ADR 0552 §4). -func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, levels, needs, packages []string) *meshdeliverychecker.VerdictsByGroupHeadsValue { +func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, levels, needs, packages []string) { p := t.pull placed := len(needs) > 0 job := &buildstep.CheckJob{Fetch: t.fetchArgs, Levels: levels, Kept: true, Packages: packages, Toolchain: t.agreed} @@ -626,7 +611,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l err = fmt.Errorf("no node record is on the bus") } if err != nil { - return fail(t.module, fmt.Sprintf("the check of %s#%d is not asked: the nodes' names the names check needs cannot be read: %v", p.Repository, p.Number, err)) + st.fail(t.module, fmt.Sprintf("the check of %s#%d is not asked: the nodes' names the names check needs cannot be read: %v", p.Repository, p.Number, err)) + return } job.NodeNames = names } @@ -655,7 +641,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l st.c.Progress.Set(what, "") st.c.Asked(a.ID) if err := st.j.Asks.Put(ctx, a); err != nil { - return fail(t.module, fmt.Sprintf("the check of %s#%d could not be asked: %v", p.Repository, p.Number, err)) + st.fail(t.module, fmt.Sprintf("the check of %s#%d could not be asked: %v", p.Repository, p.Number, err)) + return } waiting := ctx if watch := st.c.Watch; watch != nil { @@ -667,9 +654,11 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l step, err = st.j.Asks.Wait(waiting, a) if err != nil { if ctx.Err() != nil { - return fail(t.module, ctx.Err().Error()) + st.fail(t.module, ctx.Err().Error()) + return } - return fail(t.module, fmt.Sprintf("the check of %s#%d: %v", p.Repository, p.Number, err)) + st.fail(t.module, fmt.Sprintf("the check of %s#%d: %v", p.Repository, p.Number, err)) + return } d, declined := buildstep.DeclinedOf(step) if !declined { @@ -690,36 +679,40 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l if lacks == "" { lacks = strings.Join(d.Lacks, ", ") // a node off the agreed toolchain declined it (ADR 0584) } - return fail(step.Node, fmt.Sprintf("the check of %s#%d needs a node with %s, and none took it in %s: every node that pulled it lacks it (last %s)", + st.fail(step.Node, fmt.Sprintf("the check of %s#%d needs a node with %s, and none took it in %s: every node that pulled it lacks it (last %s)", p.Repository, p.Number, lacks, bound, step.Node)) + return } st.j.logf("%s#%d: the ask %s was declined on %s, which lacks %s; asked again", p.Repository, p.Number, a.ID, step.Node, strings.Join(d.Lacks, ", ")) a.ID, a.At = buildstep.NewID(), now.UTC() } - var failures []*meshdeliverychecker.VerdictsByGroupHeadsValue // The fetch and the digests are what of the ask's time its levels' running did not take (Took.Clone). took := time.Duration(0) if !step.Started.IsZero() && step.Ended.After(step.Started) { took = step.Ended.Sub(step.Started) } t.node = step.Node - st.nodes[step.Node] = true + st.facts = append(st.facts, rules.Step{Repository: p.Repository, Number: p.Number, Module: t.module, Node: step.Node}) st.c.Progress.Set(fmt.Sprintf("judge the check of %s#%d", p.Repository, p.Number), step.Node) if step.State != buildstep.StepPassed { - return fail(step.Node, fmt.Sprintf("the check of %s#%d %s: %s", p.Repository, p.Number, step.State, step.Reason)) + st.fail(step.Node, fmt.Sprintf("the check of %s#%d %s: %s", p.Repository, p.Number, step.State, step.Reason)) + return } var answer buildstep.CheckAnswer if err := json.Unmarshal(step.Result, &answer); err != nil { - return fail(step.Node, fmt.Sprintf("the check of %s#%d answered what is not a check: %s", p.Repository, p.Number, step.Result)) + st.fail(step.Node, fmt.Sprintf("the check of %s#%d answered what is not a check: %s", p.Repository, p.Number, step.Result)) + return } if t.fetched = answer.Fetch; t.fetched.Workspace == "" { - return fail(step.Node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, step.Result)) + st.fail(step.Node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, step.Result)) + return } if len(t.fetched.Conflicts) > 0 { if !st.narrowTry() { st.j.writePreview(ctx, p, append(*merged, t.fetched)) } - return fail(step.Node, fmt.Sprintf("%s#%d %s%s", p.Repository, p.Number, ConflictWords, strings.Join(t.fetched.Conflicts, ", "))) + st.fail(step.Node, fmt.Sprintf("%s#%d %s%s", p.Repository, p.Number, ConflictWords, strings.Join(t.fetched.Conflicts, ", "))) + return } t.changed = t.fetched.Changed if !changesBuildGo(p.Repository, t.changed) { @@ -727,25 +720,20 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l } if !placed { // the placed ask fetched the same merge: its preview was read from the first *merged = append(*merged, t.fetched) - if v := st.checkBinariesOf(t); v != nil { - failures = append(failures, v) // the levels still run: the verdict names every failure - } + st.checkBinariesOf(t) // the levels still run: the verdict names every failure } ran := 0.0 for _, la := range answer.Levels { if placed { if len(la.Unmet) > 0 { st.clone += max(0, took-time.Duration(ran*float64(time.Second))) - return fail(step.Node, fmt.Sprintf("the %s level of %s#%d left %s untested on %s, which was asked for a node with %s", + st.fail(step.Node, fmt.Sprintf("the %s level of %s#%d left %s untested on %s, which was asked for a node with %s", la.Level, p.Repository, p.Number, unmetNames(la.Unmet), step.Node, strings.Join(needs, ", "))) + return } la.Kept = nil // it names its packages and takes no other's kept verdict; this is only a guard against an older module } - v, elapsed := st.level(ctx, t, la) - ran += elapsed - if v != nil { - failures = append(failures, v) // the next level was run too (buildstep): its failures are named as well - } + ran += st.level(ctx, t, la) // the next level was run too (buildstep): its failures are named as well } st.clone += max(0, took-time.Duration(ran*float64(time.Second))) if unmet := buildstep.UnmetNeeds(answer.Levels); len(unmet) > 0 && !placed { @@ -765,9 +753,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l } } st.j.logf("%s#%d: %s on %s left untested; asked of a node with %s", p.Repository, p.Number, unmetOf(answer.Levels), step.Node, strings.Join(unmet, ", ")) - failures = append(failures, st.askNeeding(ctx, t, merged, again, unmet, packages)) + st.askNeeding(ctx, t, merged, again, unmet, packages) } - return joinFailures(failures...) } // unmetNames names the unmet packages and their needs. @@ -794,64 +781,32 @@ func unmetOf(levels []buildstep.LevelAnswer) string { // of the packages that passed, the kept ones taken are counted, and every test result is read and counted, also after // one that did not pass, so the verdict names every failed test of the level (issue 1921). It answers the level's // running time (its tests' and setup's). -func (st *state) level(ctx context.Context, t *target, la buildstep.LevelAnswer) (*meshdeliverychecker.VerdictsByGroupHeadsValue, float64) { - level := la.Level - a := st.levels[level] - if a == nil { - return fail(t.node, fmt.Sprintf("the check of %s#%d answered the level %q, which is no level", t.pull.Repository, t.pull.Number, level)), 0 - } - var digests map[string]string - if t.module == buildrun.GoBuildModule && la.Digest != nil { - // The packages come as a list, never keyed by a package path (issue 1930); an answer stored before reads too. - digests = la.Digest.Map() - if t.runner != "" { - t.judgedBy(la.Digest.JudgedBy) // the installed module judged it: the runner is not used again - } +func (st *state) level(ctx context.Context, t *target, la buildstep.LevelAnswer) float64 { + if t.runner != "" && t.module == buildrun.GoBuildModule && la.Digest != nil { + t.judgedBy(la.Digest.JudgedBy) // the installed module judged it: the runner is not used again } if t.runner != "" && la.Judged != "" { t.judgedBy(la.Judged) // the candidate judged the level: nothing is kept (ADR 0535 §4) } - for _, k := range la.Kept { - a.kept += k.Tests + _, elapsed, results := st.levels.Level(t.pull.Repository, t.pull.Number, t.module, t.node, la) + st.facts = append(st.facts, rules.Step{Repository: t.pull.Repository, Number: t.pull.Number, Module: t.module, Node: t.node, Level: rules.LevelFact(la)}) + var digests map[string]string + if t.module == buildrun.GoBuildModule && la.Digest != nil { + // The packages come as a list, never keyed by a package path (issue 1930); an answer stored before reads too. + digests = la.Digest.Map() } - elapsed := 0.0 - var failedResults []Tested - for _, raw := range la.Tests { - var res Tested - if err := json.Unmarshal(raw, &res); err != nil { - return fail(t.node, fmt.Sprintf("the %s level of %s#%d answered what is not a result: %v", level, t.pull.Repository, t.pull.Number, err)), elapsed - } + for _, res := range results { if t.runner != "" { t.judgedBy(res.JudgedBy) } - elapsed += res.Elapsed + res.Setup + res.Lint - if t.module == buildrun.NodeBuildModule { - if !res.NoTests { - a.run++ // a Node package is counted whole: its build module counts no tests (ADR 0484 clause 10) - } - } else { - a.run += res.Tests - a.skipped += res.Skipped - a.slowest = append(a.slowest, res.Slowest...) - } - a.elapsed += res.Elapsed - a.setup += res.Setup - a.lint += res.Lint - a.over = a.over || res.OverBound + st.stepOutOfStep(t.pull, res.OutOfStep) if len(digests) > 0 { - if _, err := st.j.Kept.KeepPasses(ctx, t.pull.Repository, level, t.toolchain, res, digests, st.j.now()); err != nil { + if _, err := st.j.Kept.KeepPasses(ctx, t.pull.Repository, la.Level, t.toolchain, res, digests, st.j.now()); err != nil { st.j.logf("%s#%d: a passing package could not be kept: %v", t.pull.Repository, t.pull.Number, err) } } - if !res.Passed { - failedResults = append(failedResults, res) - } - st.stepOutOfStep(t.pull, res.OutOfStep) } - if len(failedResults) > 0 { - return levelFailure(t, level, failedResults...), elapsed - } - return nil, elapsed + return elapsed } // changesPackage says whether the merge of a pull request changes a file inside the named paths of one of its repository's @@ -873,259 +828,3 @@ func (j *Examiner) changesPackage(p Pull, merged []Fetched) bool { } return false } - -// failedName names a failure: its package and test. -func failedName(f Failure) string { - if f.Test == "" { - return f.Package - } - return f.Package + "." + f.Test -} - -// MaxWords is the most a failed test's words in a verdict hold (issue 1901). **The words keep the failure's last lines**, -// where a test says why it failed: the first line is often something it logged before (the group r4g verdict kept a -// "judged against" line and lost the t.Fatalf after it), and the 120 characters kept before cut a docker command's echo -// before docker's own error. Four kilobytes holds a long message and is small beside a verdict's other parts. -const MaxWords = 4096 - -// failureWords is a failure's words: its output's last lines up to MaxWords, with the line the build module told apart as -// the test's error first when the output does not start with it, so the first line of the words is the cause and the -// status line, which is cut short, names it. **A failure's words are never "PASS" or "ok"** (issue 1901, its third -// meeting: a package that failed after its tests passed was worded "PASS"): go test's summary lines are dropped, and with -// no cause told apart (an older build module) the cause is the output's first "panic:", "fatal error:" or "signal:" -// line, read before the output is cut, or the words say the tests passed and the program failed after them. -func failureWords(f Failure) string { - return failureWordsAfter(f, "") -} - -// failureWordsAfter is failureWords with the line loud put first, within the same MaxWords: what the checker itself says of -// the failure (that the build module hid it), which the status line then names. -func failureWordsAfter(f Failure, loud string) string { - out, passed := dropSummary(strings.TrimSpace(f.Output)) - cause := strings.TrimSpace(f.Cause) - if cause == "" { - cause = causeIn(out) - } - switch { - case cause == "" && out == "" && passed: - cause = "the tests passed, then the test program exited with a failure: an os.Exit or a panic in TestMain or after the tests" - case cause == "" && out == "": - // The build module on main sends a package that stopped (a timeout, an exit, a kill) as its "FAIL" line alone. - cause = "the package failed with no test to name and no panic, signal or build error in its output (an older build module sent no cause)" - } - if len(cause) > MaxCause { - cause = strings.ToValidUTF8(cause[:MaxCause], "") + "..." - } - // The words keep MaxWords exactly: the loud line, the cause and their newlines, the marker and the kept lines. - budget := MaxWords - if cause != "" { - budget -= len(cause) + 1 - } - if loud != "" { - budget -= len(loud) + 1 - } - if len(out) > budget { - const marker = "... " - tail := out[len(out)-(budget-len(marker)):] - // Start on a whole line, and a whole character. - if i := strings.IndexByte(tail, '\n'); i >= 0 { - tail = strings.TrimLeft(tail[i:], "\n") - } - out = marker + strings.ToValidUTF8(tail, "") - } - if cause != "" && !strings.HasPrefix(firstLine(out), cause) { - out = strings.TrimRight(cause+"\n"+out, "\n") - } - if loud != "" { - out = strings.TrimRight(loud+"\n"+out, "\n") - } - return out -} - -// hidWords is the checker's loud line for a failure the build module sent hiding its cause (issue 1901, its thorough -// fix): with no cause, or a passing line as its cause, under the failure contract (buildstep.Hidden). It names the build -// module and the version it says it is (Tested.By, " with mesh-sdk "); one from before the contract -// says nothing of itself. "" when the failure keeps the contract. The check fails either way: only its words change. -func hidWords(module, by string, f Failure) string { - why := buildstep.Hidden(f) - if why == "" { - return "" - } - version := "a version before the failure contract" - if module == "" { - module = "an unnamed build module" - } - if m, v, ok := strings.Cut(by, " with "); ok && m != "" { - module, version = m, v - } - return fmt.Sprintf("the build module hid the failure (%s at %s): %s", module, version, why) -} - -// MaxCause is the most a cause in a failure's words holds: one long line (the 1901 review: a cause of a kilobyte could -// push the words past MaxWords). -const MaxCause = 1024 - -// dropSummary drops go test's summary lines from a failure's output ("PASS", "FAIL", "ok ", "FAIL "), and says -// whether a "PASS" was among them: the package's tests passed before it failed. -func dropSummary(out string) (string, bool) { - passed := false - lines := strings.Split(out, "\n") - kept := lines[:0] - for _, l := range lines { - // go's exact forms only (review round 1): "PASS" or "FAIL" alone, "ok \t\t