diff --git a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go index 82e45fc6..e18efc5c 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go @@ -50,7 +50,7 @@ func run(args []string, getenv func(string) string, stdout, stderr io.Writer) in if err != nil { return refuse("%v", err) } - line, err := json.Marshal(rules.Answer{V: rules.AnswerVersion, Verdict: rules.Judge(facts)}) + line, err := json.Marshal(rules.Judged(facts)) if err != nil { return refuse("the verdict cannot be written: %v", err) } diff --git a/modules/mesh-delivery-checker/internal/check/binaries.go b/modules/mesh-delivery-checker/internal/check/binaries.go index 1835be61..1fd9d02c 100644 --- a/modules/mesh-delivery-checker/internal/check/binaries.go +++ b/modules/mesh-delivery-checker/internal/check/binaries.go @@ -2,7 +2,8 @@ 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 @@ -13,30 +14,20 @@ import ( // tell, so the check does not refuse a program it did not look for. const NoProgramsReported = "the build module did not report programs: a compiled program in the change is not refused" -// 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 build module that reports no programs leaves a -// warning on the check and passes it. -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 build module that reports no programs leaves a warning on +// the check and passes it. +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 } if t.fetched.Programs == nil { st.c.note(fmt.Sprintf("%s: %s (%s)", key, NoProgramsReported, t.module)) st.c.warn(key, fmt.Sprintf("a compiled program is not refused: %s reported no programs", t.module)) st.j.logf("%s: %s (%s)", key, NoProgramsReported, t.module) - return nil + return } - 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)") - } - if len(found) > 0 { - return st.fail(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: t.fetched.Programs}}) } diff --git a/modules/mesh-delivery-checker/internal/check/builds.go b/modules/mesh-delivery-checker/internal/check/builds.go index 82e79c2c..a35b51ec 100644 --- a/modules/mesh-delivery-checker/internal/check/builds.go +++ b/modules/mesh-delivery-checker/internal/check/builds.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "maps" - "mesh-delivery-checker/rules" "slices" "sort" "strings" @@ -124,19 +123,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 *delivery.Verdict) []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, []delivery.FailedFirst) { wait, words := st.buildsOfTheCheck(ctx) if len(wait) == 0 { - return words + return words, nil } modules := slices.Sorted(maps.Keys(wait)) - if v.State == delivery.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) @@ -147,25 +147,21 @@ func (st *state) awaitBuilds(ctx context.Context, v *delivery.Verdict) []string 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 builds []delivery.FailedFirst for _, module := range modules { b, done := ended[module] switch { case !done: late = append(late, module) case b.State == delivery.BuildFailed: - f := delivery.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 != delivery.VerdictFail { - v.State, v.Failed = delivery.VerdictFail, f - continue - } - rules.AddFailure(v, f) + builds = append(builds, delivery.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, builds } 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 18944c34..00000000 --- a/modules/mesh-delivery-checker/internal/check/composition.go +++ /dev/null @@ -1,29 +0,0 @@ -package check - -import ( - "mesh-delivery-checker/rules" - "strings" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// withComposition is v with the check's composition taken in: a failed one fails the check, naming its machine. -func withComposition(v *delivery.Verdict, comp delivery.CompositionVerdict) { - v.Composition = comp.Composition() - v.Took.Composition = comp.Took - if comp.State == delivery.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 == delivery.CompositionFail { - machine := comp.Machine - if machine == "" { - machine = "the composition" - } - f := delivery.FailedFirst{Machine: machine, Words: comp.Words} - if v.State == delivery.VerdictFail { - rules.AddFailure(v, f) // the tests failed too: the composition is named after them (the operator, 2026-11-02) - return - } - v.State, v.Failed = delivery.VerdictFail, f - } -} diff --git a/modules/mesh-delivery-checker/internal/check/composition_test.go b/modules/mesh-delivery-checker/internal/check/composition_test.go index 9ba4c332..e6ae1535 100644 --- a/modules/mesh-delivery-checker/internal/check/composition_test.go +++ b/modules/mesh-delivery-checker/internal/check/composition_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "maps" + "mesh-delivery-checker/rules" "slices" "strings" "testing" @@ -14,28 +15,43 @@ import ( // fakeCompositions answers a check's composition from memory, recording the keys asked. type fakeCompositions struct { - v delivery.CompositionVerdict + v rules.Composed err error asked []string // waits answers only when its context ends. waits bool } -func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (delivery.CompositionVerdict, error) { +func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) { f.asked = append(f.asked, c.Key) if f.waits { <-ctx.Done() - return delivery.CompositionVerdict{}, 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]delivery.ComposeCandidateAnswer{}} + for _, n := range nodes { + c.Answers[n] = delivery.ComposeCandidateAnswer{Node: n, Verdict: delivery.ComposeCandidateComposed} + } + 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]delivery.ComposeCandidateAnswer{node: {Node: node, Verdict: delivery.ComposeCandidateRefused, 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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.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" @@ -67,8 +83,7 @@ func compositionMentions(v delivery.Verdict, word string) bool { // 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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.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 != delivery.VerdictFail || v.Failed.Machine != "g14" || v.Composition == nil || v.Composition.State != delivery.CompositionFail { t.Fatalf("verdict = %+v", v) @@ -89,7 +104,7 @@ func TestACompositionNotHadIsNotRun(t *testing.T) { t.Fatalf("verdict = %+v", v) } b2 := newBench(t) - f2 := &fakeCompositions{v: delivery.CompositionVerdict{State: delivery.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{} @@ -212,7 +227,7 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { return r, nil } } - comps := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"g14"}}} + comps := &fakeCompositions{v: composedOn("x", 0, "g14")} b.ex.Compositions = comps var ends *fakeEnds if row.builds != nil { @@ -265,3 +280,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 91342266..f0cadd76 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" @@ -68,17 +69,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) (delivery.CompositionVerdict, 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 := delivery.CompositionKey(c.Group, c.HeadsHash) if err != nil { - return delivery.CompositionVerdict{}, err + return rules.Composed{}, err } var nodes []string if e.Placed != nil { if nodes, err = e.Placed(); err != nil { - return delivery.CompositionVerdict{}, fmt.Errorf("the placement cannot be read, so no node can be asked: %w", err) + return rules.Composed{}, fmt.Errorf("the placement cannot be read, so no node can be asked: %w", err) } } bound := e.Bound @@ -129,7 +131,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp } else if e.Composer != nil && e.Composer() != "" { composerName = e.Composer() } - v := JudgeEngines(c.Group, c.HeadsHash, 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") } @@ -137,57 +139,6 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp 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 delivery.ComposeNoAnswer as a warning. composer names what composed, for the line. Pure. -func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]delivery.ComposeCandidateAnswer, silent map[string]string) delivery.CompositionVerdict { - v := delivery.CompositionVerdict{Group: group, Heads: heads, State: delivery.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+": "+delivery.ComposeNoAnswer) - continue - } - if a.Verdict == delivery.ComposeCandidateNotJudged { - // 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+": "+delivery.ComposeNoAnswer+" ("+a.Refused+")") - continue - } - v.Machines = append(v.Machines, node) - if a.Verdict != delivery.ComposeCandidateRefused { - continue - } - if v.Machine == "" { - v.Machine = node - } - var words []string - if a.Refused != "" { - words = append(words, a.Refused) - } - for _, m := range append(append([]delivery.ModuleComposed(nil), a.Modules...), a.Own) { - if m.State == delivery.ModuleRefusedState { - 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 = delivery.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. diff --git a/modules/mesh-delivery-checker/internal/check/engines_test.go b/modules/mesh-delivery-checker/internal/check/engines_test.go index 259fd122..189fedb5 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" @@ -80,15 +81,17 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { } cs := caseOf(t, pullOf(repoA, 7, head1)) key, _ := delivery.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) delivery.CompositionVerdict { 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) + 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) { @@ -106,7 +109,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { t.Errorf("asked %+v, want the check's candidate %s and no input", e.asked, key) } var ver delivery.Verdict - withComposition(&ver, v) + rules.JudgeComposition(&ver, rules.Composition{Composed: &last}) if ver.State != delivery.VerdictFail || ver.Failed.Machine != "laptop" { t.Errorf("the check's verdict = %+v", ver) } @@ -131,7 +134,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) { } var ver delivery.Verdict ver.State = delivery.VerdictPass - withComposition(&ver, v) + rules.JudgeComposition(&ver, rules.Composition{Composed: &last}) if ver.State != delivery.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+delivery.ComposeNoAnswer) { t.Errorf("the line = %q", composition(ver.Composition, 0, false)) } @@ -152,7 +155,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) + raw, err := ec.Compose(context.Background(), host) + v := rules.JudgeEngines(raw) if err != nil { t.Fatal(err) } @@ -186,7 +190,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) + raw, err = ec.Compose(context.Background(), host) + v = rules.JudgeEngines(raw) named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+delivery.ComposeNoAnswer+" (its node-engine sends no running state for web)") if err != nil || (v.State == delivery.CompositionPass) != row.pass || named != row.pass { t.Errorf("%+v: verdict %+v, %v", row.m, v, err) @@ -195,7 +200,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) + raw, err = ec.Compose(context.Background(), host) + v = rules.JudgeEngines(raw) if err != nil || v.State != delivery.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) 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 162f19b5..a845e029 100644 --- a/modules/mesh-delivery-checker/internal/check/every_failure_test.go +++ b/modules/mesh-delivery-checker/internal/check/every_failure_test.go @@ -91,7 +91,8 @@ 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, delivery.CompositionVerdict{State: delivery.CompositionFail, Machine: "g14", Words: "route-proxy is defined twice"}) + 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 4965b20f..2fa8dab3 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -445,8 +445,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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.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[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"m/internal/a": digA, "m/internal/b": digB}} @@ -480,7 +479,7 @@ func TestEveryFailureOfEveryMemberLevelAndTheCompositionIsNamed(t *testing.T) { got = append(got, firstFailed(delivery.Verdict{Failed: f})) } 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...) } diff --git a/modules/mesh-delivery-checker/internal/check/graph_cycle.go b/modules/mesh-delivery-checker/internal/check/graph_cycle.go index 1aa6804a..48981459 100644 --- a/modules/mesh-delivery-checker/internal/check/graph_cycle.go +++ b/modules/mesh-delivery-checker/internal/check/graph_cycle.go @@ -9,7 +9,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-delivery/sdk/graph" ) @@ -29,14 +28,14 @@ type ManifestReader interface { // 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) *delivery.Verdict { +func (st *state) refuseCycle(ctx context.Context) { if st.j.Manifests == nil || st.j.Read == nil || st.narrowTry() { - return nil + return } trunk := st.j.Manifests.ManifestDigests() req, touched := judgeRequest(trunk, st.c.Pulls) if len(touched) == 0 { - return nil + return } read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) { out := map[string]catalogue.Manifest{} @@ -51,12 +50,13 @@ func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict { } change, err := read(req.Cases[0].Manifests) if err != nil { - return st.fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error()) + st.fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error()) + return } cycle := graph.OfManifests(change).Cycle() if cycle == nil { st.c.note("the change's module graph has no cycle") - return nil + return } words := strings.Join(cycle, " -> ") if before, err := read(req.Cases[1].Manifests); err == nil { @@ -68,9 +68,9 @@ func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict { } 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 } } - return st.fail(goModule, fmt.Sprintf("the change's module graph has the cycle %s: these modules would each wait for the other for "+ + st.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/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index 2826afd7..5faa8b33 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -151,9 +151,10 @@ type TryCandidates interface { TryCandidate(ctx context.Context, group, heads string, bound time.Duration) (delivery.Candidate, error) } -// CompositionReader makes a check's composition verdict; an error is only a case that cannot be composed at all. +// 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) (delivery.CompositionVerdict, error) + Compose(ctx context.Context, c *Case) (rules.Composed, error) } func (j *Examiner) now() time.Time { @@ -234,8 +235,10 @@ type state struct { 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 - clone time.Duration - slow []string + // 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,26 +307,24 @@ func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) { st.takeTryCandidate(ctx) st.judgeManifests(ctx) st.refuseCycle(ctx) - return st.finish(ctx, rules.Judge(st.factsOf())), 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 delivery.Verdict) Outcome { - out := st.outcome(v) - st.composed(ctx, &out.Verdict) + st.compose(ctx) + answer := rules.Judged(st.factsOf()) + for _, n := range answer.Notes { + st.c.note(n) + } + 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 *delivery.Verdict) { +// 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 } @@ -331,7 +332,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) { return } if st.noCandidate != "" { - v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: st.noCandidate} + st.composition = &rules.Composition{NotRun: st.noCandidate} return } if st.j.Compositions == nil { @@ -339,23 +340,24 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) { } 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 = &delivery.Composition{State: delivery.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 + // 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 == delivery.VerdictFail + words, builds := st.awaitBuilds(ctx, failed) + c := &rules.Composition{Waits: words, BuildsFailed: builds, FPSet: st.c.FPSet} + st.composition = c 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 = &delivery.Composition{State: delivery.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 != delivery.CompositionNotRun { - v.Composition.FPSet = st.c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5) - } + c.Composed = &comp } // outcome is the verdict judged from the facts with the slowest tests, the siblings read and what judged a build module. @@ -386,7 +388,7 @@ func (st *state) fail(machine, words string) *delivery.Verdict { // factsOf are the check's facts as met: what the checking program judges. func (st *state) factsOf() rules.Facts { - return rules.Facts{V: rules.FactsVersion, Steps: st.facts, CloneMS: st.clone.Milliseconds(), Notes: st.c.Notes()} + return rules.Facts{V: rules.FactsVersion, Steps: st.facts, CloneMS: st.clone.Milliseconds(), Composition: st.composition, Notes: st.c.Notes()} } // fail is a verdict of failure at a machine. @@ -605,9 +607,7 @@ 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 { diff --git a/modules/mesh-delivery-checker/internal/check/manifests_judge.go b/modules/mesh-delivery-checker/internal/check/manifests_judge.go index fc63f5fb..718cebd5 100644 --- a/modules/mesh-delivery-checker/internal/check/manifests_judge.go +++ b/modules/mesh-delivery-checker/internal/check/manifests_judge.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "maps" + "mesh-delivery-checker/rules" "slices" "strings" @@ -31,7 +32,7 @@ const ( JudgeCase = "change" JudgeTrunk = "trunk" // judgeBetween is how the judge names the problems between manifests that no one module's removal ends. - judgeBetween = "(between manifests)" + judgeBetween = rules.BetweenManifests ) // judgePaths are the paths of mesh-delivery whose change changes the judge: a group whose mesh-delivery member changes @@ -93,16 +94,17 @@ func judgeRequest(trunk map[string]string, pulls []Pull) (req composer.Request, {Name: JudgeTrunk, Manifests: before}}}, slices.Sorted(maps.Keys(seen)) } -// judgeManifests runs the indexer's judge over the case's manifests and fails the check for each touched manifest it -// refuses. A case that touches no manifest, or an examiner that reads no trunk manifests, judges nothing; a judge that -// cannot be asked or does not answer fails the check, said, never passes it. -func (st *state) judgeManifests(ctx context.Context) *delivery.Verdict { +// judgeManifests runs the indexer's judge over the case's manifests and records its answers as a fact the rules judge +// (rules.JudgeManifests), with the trunk validator's answer on a new indexer (ADR 0633 clause 6). A case that touches no +// manifest, or an examiner that reads no trunk manifests, judges nothing; a judge that cannot be asked or does not answer +// is a failure, said, never a pass. +func (st *state) judgeManifests(ctx context.Context) { if st.j.Manifests == nil || st.narrowTry() { - return nil + return } req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.c.Pulls) if len(touched) == 0 { - return nil + return } commit, at := st.judgeHead, "the group's mesh-delivery head "+st.judgeHead if commit == "" { @@ -112,7 +114,8 @@ func (st *state) judgeManifests(ctx context.Context) *delivery.Verdict { at = "mesh-delivery's trunk " + commit } if commit == "" { - return st.fail(goModule, "the change's manifests cannot be judged: the trunk head of "+DeliveryRepository+" is not known") + st.fail(goModule, "the change's manifests cannot be judged: the trunk head of "+DeliveryRepository+" is not known") + return } // The list of predefined keys the manifests are judged against (ADR 0578 §2): the catalogue member's predefined.json // when the change alters it, else the one the indexer keeps. @@ -120,67 +123,24 @@ func (st *state) judgeManifests(ctx context.Context) *delivery.Verdict { req.Predefined = list st.c.note("the manifests are judged against the predefined keys of " + from) } - cv, trunkCase, v := st.runJudge(ctx, req, touched, commit, at, st.packagesOf(DeliveryRepository)) - if v != nil { - return v + cv, trunkCase, ok := st.runJudge(ctx, req, touched, commit, at, st.packagesOf(DeliveryRepository)) + if !ok { + return } - if v := st.refuseUnmovableIndexer(ctx, req, touched, cv, at); v != nil { - return v + m := rules.Manifests{Touched: touched, At: at, Change: cv, Trunk: trunkCase} + if !st.indexerOf(ctx, req, touched, cv, at, &m) { + return } - // A new or changed manifest is judged in full; of one the change leaves as it is, a problem the trunk already had is a - // warning (it is not refused for a rule it already broke), and a problem the change causes in it, which the trunk case - // lacks, fails the check as the change's (ADR 0579 §2). - var refused, warned []string - for _, module := range slices.Sorted(maps.Keys(cv.Manifests)) { - r := cv.Manifests[module] - if r.Accepted { - continue - } - if r.Refused == "" { // held: a verdict not given is not one given (ADR 0633 clause 6) - if slices.Contains(touched, module) { - refused = append(refused, fmt.Sprintf("the indexer's judge at %s holds the manifest of %s: it gave no verdict", at, module)) - } - continue - } - had := map[string]bool{} - for _, p := range strings.Split(trunkCase.Manifests[module].Refused, "\n") { - had[p] = true - } - var caused, old []string - for _, p := range strings.Split(r.Refused, "\n") { - if slices.Contains(touched, module) || !had[p] { - caused = append(caused, p) - } else { - old = append(old, p) - } - } - if len(caused) > 0 { - what := "the manifest of " + module - if module == judgeBetween { - what = "the catalogue" - } - refused = append(refused, fmt.Sprintf("the indexer's judge at %s refuses %s: %s", at, what, strings.Join(caused, "; "))) - } - if len(old) > 0 { - warned = append(warned, module+": "+strings.Join(old, "; ")) - } - } - if len(warned) > 0 { - st.c.note("warnings of the indexer's judge on manifests the change leaves as they are (ADR 0579 §2): " + strings.Join(warned, " | ")) - } - if len(refused) > 0 { - return st.fail(goModule, strings.Join(refused, "\n")) - } - st.c.note(fmt.Sprintf("the manifests of %s are accepted by the indexer's judge at %s", strings.Join(touched, ", "), at)) - return nil + st.facts = append(st.facts, rules.Step{Module: goModule, Manifests: &m}) } // runJudge asks the Go build seat to build the judge at the commit and run it over the request, and reads its change and -// trunk cases; a failed verdict when it cannot be asked or does not answer. -func (st *state) runJudge(ctx context.Context, req composer.Request, touched []string, commit, at string, versions delivery.PackageVersions) (change, trunk composer.CaseVerdict, v *delivery.Verdict) { +// trunk cases; false, the failure recorded, when it cannot be asked or does not answer. +func (st *state) runJudge(ctx context.Context, req composer.Request, touched []string, commit, at string, versions delivery.PackageVersions) (change, trunk composer.CaseVerdict, ok bool) { raw, err := json.Marshal(req) if err != nil { - return change, trunk, st.fail(goModule, err.Error()) + st.fail(goModule, err.Error()) + return change, trunk, false } job := delivery.ComposeJob{Fetch: withPackages(map[string]any{"repository": DeliveryRepository, "commit": commit}, versions), Dir: JudgeDir, Package: JudgePackage, Request: raw, Reader: "the indexer's judge at " + at} @@ -189,48 +149,48 @@ func (st *state) runJudge(ctx context.Context, req composer.Request, touched []s st.c.Progress.Set(fmt.Sprintf("the judge ask %s of the manifests of %s", a.ID, strings.Join(touched, ", ")), "") st.c.Asked(a.ID) if err := st.j.Asks.Put(ctx, a); err != nil { - return change, trunk, st.fail(goModule, "the judge of the change's manifests could not be asked: "+err.Error()) + st.fail(goModule, "the judge of the change's manifests could not be asked: "+err.Error()) + return change, trunk, false } step, err := st.j.Asks.Wait(ctx, a) if err != nil { - return change, trunk, st.fail(goModule, "the judge of the change's manifests: "+err.Error()) + st.fail(goModule, "the judge of the change's manifests: "+err.Error()) + return change, trunk, false } change, trunk, why := judgeAnswer(step) if why != "" { - return change, trunk, st.fail(goModule, fmt.Sprintf("the indexer's judge at %s did not judge the change's manifests: %s", at, why)) + st.fail(goModule, fmt.Sprintf("the indexer's judge at %s did not judge the change's manifests: %s", at, why)) + return change, trunk, false } - return change, trunk, nil + return change, trunk, true } -// CatalogSeat is the seat the indexer holds: the module whose manifest claims it is the indexer, found by the claim and -// never by its name (novox/hq ADR 0633 clause 6, ADR 0601 §4). -const CatalogSeat = "mesh-catalog" +// CatalogSeat is the seat the indexer holds (rules.CatalogSeat). +const CatalogSeat = rules.CatalogSeat -// refuseUnmovableIndexer fails the check when the trunk's manifest validator, the one the running parts hold, would -// refuse or hold the change's new indexer manifest: such an indexer would never run, and every revision behind it would -// wait for ever (ADR 0633 clause 6). The indexer is the touched module whose manifest claims the seat mesh-catalog. -// When the change judges with its own head's manifest validator (it changes it), the manifest is judged by the trunk's -// as well. The verdicts read are the trunk's manifest validator's: its change case, where refused, held (not accepted -// with no reason) or with a field set aside (a field the running manifest validator does not know waits, ADR 0601 §3) -// each fail it, naming the module and the problems. -func (st *state) refuseUnmovableIndexer(ctx context.Context, req composer.Request, touched []string, cv composer.CaseVerdict, judgedAt string) *delivery.Verdict { +// indexerOf finds the touched module whose manifest claims the seat mesh-catalog, the indexer, and puts in m the trunk's +// manifest validator's change case on it, the one the running parts hold (ADR 0633 clause 6): when the change judges +// with its own head's validator, or at other package versions than the latest published, the trunk's is asked as well. +// False, the failure recorded, when a manifest cannot be read or the trunk's validator cannot be asked. +func (st *state) indexerOf(ctx context.Context, req composer.Request, touched []string, cv composer.CaseVerdict, judgedAt string, m *rules.Manifests) bool { if st.j.Read == nil { - return nil + return true } var indexer string for _, module := range touched { - m, err := st.j.Read.Manifest(ctx, req.Cases[0].Manifests[module].Digest) + man, err := st.j.Read.Manifest(ctx, req.Cases[0].Manifests[module].Digest) if err != nil { - return st.fail(goModule, fmt.Sprintf("the manifest of %s cannot be read, so the indexer cannot be ruled out: %v", module, err)) + st.fail(goModule, fmt.Sprintf("the manifest of %s cannot be read, so the indexer cannot be ruled out: %v", module, err)) + return false } - for _, c := range m.Claims { + for _, c := range man.Claims { if c.Name == CatalogSeat { indexer = module } } } if indexer == "" { - return nil + return true } // The validator the running parts hold is the trunk's, built at the latest published versions alone, never at the group's // heads (ADR 0633 clause 6): a group's mesh-sdk or mesh-contracts head must not change the validator that judges the new @@ -241,32 +201,17 @@ func (st *state) refuseUnmovableIndexer(ctx context.Context, req composer.Reques trunk = st.j.Trunks.Heads()[DeliveryRepository] } if trunk == "" { - return st.fail(goModule, "the new indexer cannot be judged by the trunk's manifest validator: the trunk head of "+DeliveryRepository+" is not known") + st.fail(goModule, "the new indexer cannot be judged by the trunk's manifest validator: the trunk head of "+DeliveryRepository+" is not known") + return false } - var v *delivery.Verdict judgedAt = "mesh-delivery's trunk " + trunk - if cv, _, v = st.runJudge(ctx, req, touched, trunk, judgedAt, trunkVersions); v != nil { - return v + var ok bool + if cv, _, ok = st.runJudge(ctx, req, touched, trunk, judgedAt, trunkVersions); !ok { + return false } } - r, ok := cv.Manifests[indexer] - var problems []string - switch { - case !ok: - problems = append(problems, "the manifest validator gave no verdict") - case r.Refused != "": - problems = append(problems, strings.Split(r.Refused, "\n")...) - case !r.Accepted: - problems = append(problems, "the manifest validator holds it, with no verdict yet") - case r.SetAside != "": - problems = append(problems, "the manifest validator does not know its fields "+r.SetAside+", so it waits") - } - if len(problems) > 0 { - return st.fail(goModule, fmt.Sprintf("the indexer %s, the holder of the seat %s, would never run: the trunk's manifest validator at %s does not accept its manifest: %s", - indexer, CatalogSeat, judgedAt, strings.Join(problems, "; "))) - } - st.c.note(fmt.Sprintf("the new indexer's manifest of %s is accepted by the trunk's manifest validator at %s", indexer, judgedAt)) - return nil + m.Indexer, m.IndexerAt, m.OfIndexer = indexer, judgedAt, &cv + return true } // judgeAnswer reads the judge's change and trunk cases from its step, or why it cannot. diff --git a/modules/mesh-delivery-checker/internal/check/results.go b/modules/mesh-delivery-checker/internal/check/results.go index 7d26e72d..4095b9c1 100644 --- a/modules/mesh-delivery-checker/internal/check/results.go +++ b/modules/mesh-delivery-checker/internal/check/results.go @@ -52,13 +52,8 @@ type Fetched struct { Programs *[]ProgramFile `json:"programs,omitempty"` } -// ProgramFile is a changed file that is a compiled program, and its format (ELF, Mach-O or PE). -type ProgramFile struct { - Path string `json:"path"` - Kind string `json:"kind"` - // Commit is set for a program a commit of the branch added and a later one removed: it stays in history once merged. - Commit string `json:"commit,omitempty"` -} +// ProgramFile is a changed file that is a compiled program (rules.ProgramFile). +type ProgramFile = rules.ProgramFile // The test step's answer is read by the judging rules (milestone 47/B0); these name their shapes here. type ( diff --git a/modules/mesh-delivery-checker/internal/check/try_test.go b/modules/mesh-delivery-checker/internal/check/try_test.go index a5de63fb..d3eb73ff 100644 --- a/modules/mesh-delivery-checker/internal/check/try_test.go +++ b/modules/mesh-delivery-checker/internal/check/try_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "mesh-delivery-checker/rules" "slices" "strings" "testing" @@ -167,7 +168,7 @@ func (f *fakeTryCandidates) TryCandidate(_ context.Context, group, heads string, // wholeBench makes the bench judge a whole try as the checker does: the composition on every placed node and the // indexer's candidate of the try's group. func wholeBench(b *bench, cand delivery.Candidate) (*fakeCompositions, *fakeTryCandidates) { - comps := &fakeCompositions{v: delivery.CompositionVerdict{State: delivery.CompositionPass, SDK: "v0.1.14-x", Machines: []string{"g14", "shanks"}, Took: 40}} + comps := &fakeCompositions{v: composedOn("v0.1.14-x", 40, "g14", "shanks")} cands := &fakeTryCandidates{cand: cand} b.ex.Compositions, b.ex.Candidates = comps, cands return comps, cands @@ -366,21 +367,21 @@ func TestAFailingTryNamesWhatFailedAndAStepThatCannotRunIsAnError(t *testing.T) for _, row := range []struct { name string cand *fakeTryCandidates - comp delivery.CompositionVerdict + comp *rules.Composed state, said string }{ {name: "the indexer wrote no candidate", cand: &fakeTryCandidates{err: errors.New("the indexer wrote no candidate within 5m0s")}, state: TryError, said: "the indexer wrote no candidate within 5m0s"}, - {name: "a node that gave no composition answer", cand: &fakeTryCandidates{}, comp: delivery.CompositionVerdict{State: delivery.CompositionPass, SDK: "x", Warnings: []string{"g14: " + delivery.ComposeNoAnswer}}, + {name: "a node that gave no composition answer", cand: &fakeTryCandidates{}, comp: &rules.Composed{Composer: "x", Nodes: []string{"g14"}}, state: TryError, said: "g14: " + delivery.ComposeNoAnswer}, - {name: "a composition refused on a node", cand: &fakeTryCandidates{}, comp: delivery.CompositionVerdict{State: delivery.CompositionFail, Machine: "g14", Words: "not composed on g14: web: needs a state mail does not keep"}, + {name: "a composition refused on a node", cand: &fakeTryCandidates{}, comp: ptr(refusedOn("x", "g14", "web: needs a state mail does not keep")), state: TryFail, said: "not composed on g14: web: needs a state mail does not keep"}, } { kv := newMemKV() b := newBench(t) comps, _ := wholeBench(b, delivery.Candidate{}) - b.ex.Candidates, comps.v = row.cand, row.comp - if row.comp.State == "" { - comps.v.State = delivery.CompositionPass + b.ex.Candidates = row.cand + if row.comp != nil { + comps.v = *row.comp } id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now()) var ends []string diff --git a/modules/mesh-delivery-checker/rules/facts.go b/modules/mesh-delivery-checker/rules/facts.go index b3f33e28..874dfdc9 100644 --- a/modules/mesh-delivery-checker/rules/facts.go +++ b/modules/mesh-delivery-checker/rules/facts.go @@ -34,6 +34,9 @@ type Facts struct { Steps []Step `json:"steps,omitempty"` // CloneMS is how much of the steps' time their levels' running did not take (delivery.Took.Clone). CloneMS int64 `json:"clone-ms,omitempty"` + // Composition is how the check composed, after every step; nil when it was not composed (a narrowed try, a checker + // that composes nothing): its composition is then not run, unsaid. + Composition *Composition `json:"composition,omitempty"` // Notes are the running checker's notes of the check, for a reader. Notes []string `json:"notes,omitempty"` } @@ -46,9 +49,10 @@ type Member struct { Trunk string `json:"trunk,omitempty"` } -// Step is one fact of a member's check step: the node that ran it, and either one level its build module answered -// (Level, as the build module gave it) or a failure the asking met (Failed: a step not asked, refused or not answered, -// a conflict, a binary, worded by the running checker). A step with neither only says which node ran it. +// Step is one fact of the check, in the order met: the node that ran it, and one of a level its build module answered +// (Level, as the build module gave it), what its fetch read of compiled programs (Programs), the manifest validator's +// answers (Manifests) or a failure the asking met (Failed: a step not asked, refused or not answered, a conflict, worded +// by the running checker). A step with none only says which node ran it. type Step struct { Repository string `json:"repository"` Number int `json:"number"` @@ -56,6 +60,8 @@ type Step struct { Node string `json:"node,omitempty"` Level *delivery.LevelAnswer `json:"level,omitempty"` Failed *delivery.FailedFirst `json:"failed,omitempty"` + Programs *Programs `json:"programs,omitempty"` + Manifests *Manifests `json:"manifests,omitempty"` } // DecodeFacts reads facts, refusing a version this program does not read, naming it. @@ -131,9 +137,14 @@ func (t Tally) Level(repository string, number int, module, node string, la deli return nil, elapsed, results } -// Judge is the verdict of the facts: every failure in the order met, joined and settled, or a pass; the levels' -// counts, times, machines and the levels over their bound; the composition not yet run (the running checker composes). -func Judge(f Facts) delivery.Verdict { +// Judge is the verdict of the facts (Judged without its notes). +func Judge(f Facts) delivery.Verdict { return Judged(f).Verdict } + +// Judged is the answer of the facts: every failure in the order met, joined and settled, or a pass; the levels' +// counts, times, machines and the levels over their bound; then the composition taken in; and the notes the rules give +// a reader (the manifest validator's warnings and acceptances). +func Judged(f Facts) Answer { + var notes []string tally := NewTally() var failures []*delivery.Verdict nodes := map[string]bool{} @@ -148,6 +159,16 @@ func Judge(f Facts) delivery.Verdict { if v, _, _ := tally.Level(s.Repository, s.Number, s.Module, s.Node, *s.Level); v != nil { failures = append(failures, v) } + case s.Programs != nil: + if ff := JudgePrograms(s.Node, *s.Programs); ff != nil { + failures = append(failures, &delivery.Verdict{State: delivery.VerdictFail, Failed: *ff}) + } + case s.Manifests != nil: + ff, said := JudgeManifests(*s.Manifests) + notes = append(notes, said...) + if ff != nil { + failures = append(failures, &delivery.Verdict{State: delivery.VerdictFail, Failed: *ff}) + } } } v := delivery.Verdict{State: delivery.VerdictPass} @@ -156,7 +177,10 @@ func Judge(f Facts) delivery.Verdict { } v.Group, v.Heads, v.Trunk = f.Group, f.Heads, f.Trunk Outcome(&v, tally, f.CloneMS, nodes) - return v + if f.Composition != nil { + JudgeComposition(&v, *f.Composition) + } + return Answer{V: AnswerVersion, Verdict: v, Notes: notes} } // Outcome fills a verdict's levels (on a pass, or when it names none), times, machines and levels over their bound from @@ -211,8 +235,10 @@ func failAt(machine, words string) *delivery.Verdict { // AnswerVersion is the version of Answer this program writes. const AnswerVersion = 1 -// Answer is what the checking program answers on the last line of its standard output: its version and the verdict. +// Answer is what the checking program answers on the last line of its standard output: its version, the verdict and the +// notes its rules give a reader of the check. type Answer struct { V int `json:"v"` Verdict delivery.Verdict `json:"verdict"` + Notes []string `json:"notes,omitempty"` } diff --git a/modules/mesh-delivery-checker/rules/judged.go b/modules/mesh-delivery-checker/rules/judged.go new file mode 100644 index 00000000..6d1b52f0 --- /dev/null +++ b/modules/mesh-delivery-checker/rules/judged.go @@ -0,0 +1,277 @@ +package rules + +import ( + "fmt" + "maps" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The facts the manifest validator, the fetch's programs and the composer answered, and the rules that judge them +// (milestone 47/B0, D1b): the running checker asks and records; these rules give the verdict. + +// manifestsMachine is the machine a manifest's failure names: the build seat that ran the manifest validator. +const manifestsMachine = buildrun.GoBuildModule + +// CatalogSeat is the seat the indexer holds: the module whose manifest claims it is the indexer, found by the claim and +// never by its name (novox/hq ADR 0633 clause 6, ADR 0601 §4). +const CatalogSeat = "mesh-catalog" + +// BetweenManifests is the manifest validator's name for what it judges between manifests (the catalogue as a whole). +const BetweenManifests = "(between manifests)" + +// Manifests are the manifest validator's answers on the manifests a check touches (ADR 0578 §2, ADR 0579 §2, ADR 0633 +// clause 6): its change and trunk cases where it judged (At), and, when a touched module claims the seat mesh-catalog, +// that indexer and the trunk validator's change case on it (IndexerAt names where it judged). +type Manifests struct { + Touched []string `json:"touched"` + At string `json:"at"` + Change composer.CaseVerdict `json:"change"` + Trunk composer.CaseVerdict `json:"trunk"` + Indexer string `json:"indexer,omitempty"` + IndexerAt string `json:"indexer-at,omitempty"` + OfIndexer *composer.CaseVerdict `json:"of-indexer,omitempty"` +} + +// Programs are what one member's fetch read of the change's compiled programs (issue 1577): Files nil when the build +// module reported none (an older module: not refused, the running checker warns), else each changed file that is one. +type Programs struct { + Key string `json:"key"` + Files *[]ProgramFile `json:"files"` +} + +// ProgramFile is a changed file that is a compiled program, its format (ELF, Mach-O or PE) and, when it was added in an +// earlier commit of the branch and removed later, that commit. +type ProgramFile struct { + Path string `json:"path"` + Kind string `json:"kind"` + Commit string `json:"commit,omitempty"` +} + +// Composition is how the check's composition went (ADR 0593 §7, §9; ADR 0603 §10; ADR 0548 §5): kept from the check it +// carries, not run (NotRun says why), or composed by the engines; BuildsFailed are the candidate's builds that failed +// before it composed, worded by the running checker; Waits are what it composed without waiting on, as warnings. +type Composition struct { + Kept *delivery.Composition `json:"kept,omitempty"` + NotRun string `json:"not-run,omitempty"` + BuildsFailed []delivery.FailedFirst `json:"builds-failed,omitempty"` + Waits []string `json:"waits,omitempty"` + Composed *Composed `json:"composed,omitempty"` + FPSet string `json:"fp-set,omitempty"` +} + +// Composed are the engines' answers to one composition: the nodes placed, each answering node's answer, each silent +// node with why (for the journal only), what composed (the node-engine's commit), the composer's own warnings and how +// long it took in milliseconds. +type Composed struct { + Group string `json:"group"` + Heads string `json:"heads"` + Composer string `json:"composer"` + Nodes []string `json:"nodes"` + Answers map[string]delivery.ComposeCandidateAnswer `json:"answers,omitempty"` + Silent map[string]string `json:"silent,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Took int64 `json:"took,omitempty"` +} + +// JudgeManifests is the failure of the manifests' facts (nil when they pass) and the notes a reader gets: the trunk +// validator refusing, holding or setting aside the new indexer's manifest fails first (ADR 0633 clause 6); else a new or +// changed manifest is judged in full, a held one is refused, and of one the change leaves as it is a problem the trunk +// already had is a warning while one the change causes fails the check (ADR 0579 §2). +func JudgeManifests(m Manifests) (*delivery.FailedFirst, []string) { + var notes []string + if m.Indexer != "" { + var problems []string + ok := false + var iv composer.ManifestRead + if m.OfIndexer != nil { + iv, ok = m.OfIndexer.Manifests[m.Indexer] + } + switch { + case !ok: + problems = append(problems, "the manifest validator gave no verdict") + case iv.Refused != "": + problems = append(problems, strings.Split(iv.Refused, "\n")...) + case !iv.Accepted: + problems = append(problems, "the manifest validator holds it, with no verdict yet") + case iv.SetAside != "": + problems = append(problems, "the manifest validator does not know its fields "+iv.SetAside+", so it waits") + } + if len(problems) > 0 { + return &delivery.FailedFirst{Machine: manifestsMachine, Words: fmt.Sprintf("the indexer %s, the holder of the seat %s, would never run: the trunk's manifest validator at %s does not accept its manifest: %s", + m.Indexer, CatalogSeat, m.IndexerAt, strings.Join(problems, "; "))}, nil + } + notes = append(notes, fmt.Sprintf("the new indexer's manifest of %s is accepted by the trunk's manifest validator at %s", m.Indexer, m.IndexerAt)) + } + var refused, warned []string + for _, module := range slices.Sorted(maps.Keys(m.Change.Manifests)) { + r := m.Change.Manifests[module] + if r.Accepted { + continue + } + if r.Refused == "" { // held: a verdict not given is not one given (ADR 0633 clause 6) + if slices.Contains(m.Touched, module) { + refused = append(refused, fmt.Sprintf("the indexer's judge at %s holds the manifest of %s: it gave no verdict", m.At, module)) + } + continue + } + had := map[string]bool{} + for _, p := range strings.Split(m.Trunk.Manifests[module].Refused, "\n") { + had[p] = true + } + var caused, old []string + for _, p := range strings.Split(r.Refused, "\n") { + if slices.Contains(m.Touched, module) || !had[p] { + caused = append(caused, p) + } else { + old = append(old, p) + } + } + if len(caused) > 0 { + what := "the manifest of " + module + if module == BetweenManifests { + what = "the catalogue" + } + refused = append(refused, fmt.Sprintf("the indexer's judge at %s refuses %s: %s", m.At, what, strings.Join(caused, "; "))) + } + if len(old) > 0 { + warned = append(warned, module+": "+strings.Join(old, "; ")) + } + } + if len(warned) > 0 { + notes = append(notes, "warnings of the indexer's judge on manifests the change leaves as they are (ADR 0579 §2): "+strings.Join(warned, " | ")) + } + if len(refused) > 0 { + return &delivery.FailedFirst{Machine: manifestsMachine, Words: strings.Join(refused, "\n")}, notes + } + return nil, append(notes, fmt.Sprintf("the manifests of %s are accepted by the indexer's judge at %s", strings.Join(m.Touched, ", "), m.At)) +} + +// JudgePrograms fails a member whose change adds or modifies a compiled program, naming each file (issue 1577); a fetch +// that reported none, or found none, passes. +func JudgePrograms(machine string, p Programs) *delivery.FailedFirst { + if p.Files == nil { + return nil + } + var found []string + for _, f := range *p.Files { + if f.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)", f.Path, short12(f.Commit))) + continue + } + found = append(found, f.Path+": a compiled program is build output: remove it and add it to .gitignore (issue 1577)") + } + if len(found) == 0 { + return nil + } + return &delivery.FailedFirst{Machine: machine, Words: fmt.Sprintf("%s: %s", p.Key, strings.Join(found, "; "))} +} + +func short12(s string) string { + if len(s) > 12 { + return s[:12] + } + return s +} + +// 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 placed node without an answer is named with delivery.ComposeNoAnswer as a warning. +func JudgeEngines(c Composed) delivery.CompositionVerdict { + v := delivery.CompositionVerdict{Group: c.Group, Heads: c.Heads, State: delivery.CompositionPass, SDK: c.Composer, Took: c.Took} + sorted := append([]string(nil), c.Nodes...) + sort.Strings(sorted) + var fails []string + for _, node := range sorted { + a, answered := c.Answers[node] + if !answered { + v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer) + continue + } + if a.Verdict == delivery.ComposeCandidateNotJudged { + // 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+": "+delivery.ComposeNoAnswer+" ("+a.Refused+")") + continue + } + v.Machines = append(v.Machines, node) + if a.Verdict != delivery.ComposeCandidateRefused { + continue + } + if v.Machine == "" { + v.Machine = node + } + var words []string + if a.Refused != "" { + words = append(words, a.Refused) + } + for _, m := range append(append([]delivery.ModuleComposed(nil), a.Modules...), a.Own) { + if m.State == delivery.ModuleRefusedState { + 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 = delivery.CompositionFail, strings.Join(fails, "; ") + } + if len(sorted) == 0 { + v.Warnings = append(v.Warnings, "no node is placed, so the change was composed on none") + } + v.Warnings = append(v.Warnings, c.Warnings...) + return v +} + +// JudgeComposition takes the composition's facts into the verdict: a failed build of the candidate fails it, named after +// the tests' failures; a failed composition fails it, naming its machine; a passed one with warnings says them. +func JudgeComposition(v *delivery.Verdict, c Composition) { + if c.Kept != nil { + kept := *c.Kept + v.Composition = &kept + return + } + for _, f := range c.BuildsFailed { + if v.State != delivery.VerdictFail { + v.State, v.Failed = delivery.VerdictFail, f + continue + } + AddFailure(v, f) + } + if c.NotRun != "" { + v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: c.NotRun} + return + } + if c.Composed == nil { + return + } + comp := JudgeEngines(*c.Composed) + comp.Warnings = append(comp.Warnings, c.Waits...) // ADR 0603 §10 + v.Composition = comp.Composition() + v.Took.Composition = comp.Took + if comp.State == delivery.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 == delivery.CompositionFail { + machine := comp.Machine + if machine == "" { + machine = "the composition" + } + f := delivery.FailedFirst{Machine: machine, Words: comp.Words} + if v.State == delivery.VerdictFail { + AddFailure(v, f) // the tests failed too: the composition is named after them (the operator, 2026-11-02) + } else { + v.State, v.Failed = delivery.VerdictFail, f + } + } + if v.Composition != nil && v.Composition.State != delivery.CompositionNotRun { + v.Composition.FPSet = c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5) + } +}