Draw a plan's order per module from the graph and the manifests' change, and read no after: line (issue 977, part B, ADR 0419)

This commit is contained in:
2026-10-17 20:57:30 +02:00
parent 0d83c6ffc9
commit e43d52c1ea
15 changed files with 710 additions and 363 deletions
+40 -90
View File
@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -17,34 +16,36 @@ import (
"github.com/novox/mesh-controller/internal/testbus"
)
// novox/hq ADR 0239 decision 4: a group's order, from the graph and the pull requests, the same on every
// reading — the build agent before what it builds, the controller before a manifest that needs it, the
// node-engine before the controller, a declared `after:` — and a contradiction named, never ordered.
func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) {
// novox/hq ADR 0419: a group's order, from the module graph and the manifests' change alone, the same on every
// reading — what a module stands on before it, the controller before a manifest that needs it, the node-engine
// before the controller, a verb's server before its caller — and no `after:` line read.
func TestAGroupIsOrderedByTheGraphAndTheManifestsChange(t *testing.T) {
members := []orderMember{
{ID: "cat", Repository: "novox/mesh-catalog"},
{ID: "ctl", Repository: "novox/mesh-controller"},
{ID: "host", Repository: "novox/mesh-host"},
{ID: "agent", Repository: "novox/build-agent"},
{ID: "app", Repository: "novox/app", After: []string{"lab"}},
{ID: "base", Repository: "novox/base"},
{ID: "app", Repository: "novox/app"},
{ID: "lab", Repository: "novox/lab"},
}
reach := map[string]orderReach{
"cat": {Moved: []string{"gitea"}, Manifests: []string{"modules/gitea/module.json"}},
"ctl": {Moved: []string{"mesh-controller"}, Manifests: []string{"module.json"}},
"host": {Moved: []string{"mesh-host"}, Manifests: []string{"module.json"}},
"agent": {Moved: []string{"build-agent"}},
"app": {Moved: []string{"app"}},
"lab": {Moved: []string{"lab"}},
"cat": {Moved: []string{"gitea"}, Manifests: []string{"modules/gitea/module.json"}},
"ctl": {Moved: []string{"mesh-controller"}, Manifests: []string{"module.json"}},
"host": {Moved: []string{"mesh-host"}, Manifests: []string{"module.json"}},
"base": {Moved: []string{"base"}},
"app": {Moved: []string{"app"}},
"lab": {Moved: []string{"lab"}},
}
edges := []inventory.Edge{{From: "app", To: "build-agent", Kind: inventory.EdgeBuiltBy},
edges := []inventory.Edge{{From: "app", To: "base", Kind: inventory.EdgeStandsOn},
{From: "gitea", To: "postgres", Kind: inventory.EdgeDeclared}}
got := orderOf(members, reach, edges)
changes := map[string]moduleChange{"gitea": {Manifest: true}, "mesh-controller": {Manifest: true},
"mesh-host": {Manifest: true}, "lab": {Serves: []string{"seat.v"}}, "app": {Invokes: []string{"seat.v"}}}
got := orderOf(members, reach, edges, changes)
if len(got.Cycle) > 0 {
t.Fatalf("a cycle where there is none: %v", got.Cycle)
}
// By repository among those with nothing before them: the build agent, the lab, then what waited on both.
want := []string{"agent", "lab", "app", "host", "ctl", "cat"}
// By repository among those with nothing before them: app waits for base and lab; gitea's catalogue for the controller.
want := []string{"base", "lab", "app", "host", "ctl", "cat"}
if !reflect.DeepEqual(got.Order, want) {
t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs)
}
@@ -52,95 +53,44 @@ func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) {
for _, p := range got.Pairs {
why[p.Before+">"+p.After] = p.Why
}
for pair, reason := range map[string]string{"host>ctl": orderEngineFirst, "ctl>cat": orderVersionSkew,
"ctl>host": "", "agent>app": orderBuiltBy, "lab>app": orderDeclared} {
for pair, reason := range map[string]string{"host>ctl": ruleEngineFirst, "ctl>cat": ruleVersionSkew,
"ctl>host": "", "base>app": orderBuiltBy, "lab>app": ruleVerbBeforeCaller + ": seat.v"} {
if why[pair] != reason {
t.Errorf("%s is ordered %q, wanted %q", pair, why[pair], reason)
}
}
// The same members in another order read the same.
shuffled := []orderMember{members[5], members[3], members[0], members[4], members[2], members[1]}
if again := orderOf(shuffled, reach, edges); !reflect.DeepEqual(again.Order, want) {
if again := orderOf(shuffled, reach, edges, changes); !reflect.DeepEqual(again.Order, want) {
t.Fatalf("another reading ordered %v", again.Order)
}
// A declared order against an inferred one wins (novox/hq ADR 0249), and the pair says what it won over.
members[1].After = []string{"mesh-catalog"}
got = orderOf(members, reach, edges)
if len(got.Cycle) > 0 || len(got.Contradictions) > 0 {
t.Fatalf("a declared order did not win: cycle %v, %+v", got.Cycle, got.Contradictions)
}
if want := []string{"agent", "lab", "app", "cat", "host", "ctl"}; !reflect.DeepEqual(got.Order, want) {
t.Fatalf("ordered %v, wanted %v", got.Order, want)
}
if !slices.ContainsFunc(got.Pairs, func(p orderPair) bool {
return p.Before == "cat" && p.After == "ctl" && p.Why == orderDeclared && reflect.DeepEqual(p.Over, []string{orderVersionSkew})
}) || slices.ContainsFunc(got.Pairs, func(p orderPair) bool { return p.Before == "ctl" && p.After == "cat" }) {
t.Fatalf("the declared pair does not say it won over version skew: %+v", got.Pairs)
}
// Declared both ways is a contradiction no precedence resolves: a cycle, and how to end it.
members[0].After = []string{"novox/mesh-controller"}
got = orderOf(members, reach, edges)
if !reflect.DeepEqual(got.Cycle, []string{"cat", "ctl"}) || len(got.Contradictions) != 1 {
t.Fatalf("the cycle is %v, ordered %v, contradictions %+v", got.Cycle, got.Order, got.Contradictions)
}
if c := got.Contradictions[0]; !reflect.DeepEqual(c.Members, []string{"cat", "ctl"}) ||
!strings.Contains(c.Said, "remove one of the `after:` lines") {
t.Fatalf("the contradiction is said %+v", c)
}
}
// novox/hq ADR 0249: when two rules order one pair both ways, the rule of higher precedence wins and the
// pair says what it overruled; rules of one rank both ways are refused, naming both and how to declare.
func TestAnOrderRuleYieldsToAStrongerOne(t *testing.T) {
// A cycle is the one refusal, and it names every module and edge. A description's `after:` line changes nothing.
func TestAGroupInACycleIsNamedAndNothingElseIsRefused(t *testing.T) {
members := []orderMember{
{ID: "ctl", Repository: "novox/mesh-controller", Number: 7},
{ID: "host", Repository: "novox/mesh-host", Number: 9},
{ID: "x", Repository: "novox/x", Number: 7},
{ID: "y", Repository: "novox/y", Number: 9},
}
reach := map[string]orderReach{
"ctl": {Moved: []string{"mesh-controller", "build-agent"}},
"host": {Moved: []string{"mesh-host"}},
reach := map[string]orderReach{"x": {Moved: []string{"mx"}}, "y": {Moved: []string{"my"}}}
changes := map[string]moduleChange{
"mx": {Serves: []string{"s.a"}, Invokes: []string{"s.b"}},
"my": {Serves: []string{"s.b"}, Invokes: []string{"s.a"}},
}
builtBy := []inventory.Edge{{From: "mesh-host", To: "build-agent", Kind: inventory.EdgeBuiltBy}}
// Built by outranks engine before controller.
got := orderOf(members, reach, builtBy)
if !reflect.DeepEqual(got.Order, []string{"ctl", "host"}) || len(got.Cycle) > 0 {
t.Fatalf("ordered %v, cycle %v", got.Order, got.Cycle)
got := orderOf(members, reach, nil, changes)
if !reflect.DeepEqual(got.Cycle, []string{"x", "y"}) || len(got.Order) > 0 {
t.Fatalf("a cycle was ordered: %v, cycle %v", got.Order, got.Cycle)
}
if want := []orderPair{{Before: "ctl", After: "host", Why: orderBuiltBy, Over: []string{orderEngineFirst}}}; !reflect.DeepEqual(got.Pairs, want) {
t.Fatalf("pairs %+v, wanted %+v", got.Pairs, want)
}
// A declared line outranks both.
members[0].After = []string{"mesh-host"}
got = orderOf(members, reach, builtBy)
if !reflect.DeepEqual(got.Order, []string{"host", "ctl"}) || len(got.Cycle) > 0 ||
!reflect.DeepEqual(got.Pairs[0].Over, []string{orderBuiltBy}) || got.Pairs[0].Why != orderDeclared {
t.Fatalf("a declared order did not win: %v %+v", got.Order, got.Pairs)
}
// Without the build agent, engine before controller stands alone.
members[0].After = nil
got = orderOf(members, map[string]orderReach{"ctl": {Moved: []string{"mesh-controller"}}, "host": reach["host"]}, builtBy)
if !reflect.DeepEqual(got.Order, []string{"host", "ctl"}) || got.Pairs[0].Why != orderEngineFirst || len(got.Pairs[0].Over) > 0 {
t.Fatalf("engine before controller alone: %v %+v", got.Order, got.Pairs)
}
// Built by both ways is one rank against itself: refused, both rules named, and how to declare.
both := append(builtBy, inventory.Edge{From: "build-agent", To: "mesh-host", Kind: inventory.EdgeStandsOn})
got = orderOf(members, reach, both)
if !reflect.DeepEqual(got.Cycle, []string{"ctl", "host"}) || len(got.Order) > 0 || len(got.Contradictions) != 1 {
t.Fatalf("a contradiction was ordered: %v, cycle %v, %+v", got.Order, got.Cycle, got.Contradictions)
}
said := got.Contradictions[0].Said
for _, w := range []string{"built by puts ctl first", "built by puts host first",
"`after: novox/mesh-controller` in novox/mesh-host#9's description", "`after: novox/mesh-host` in novox/mesh-controller#7's"} {
if !strings.Contains(said, w) {
t.Errorf("the contradiction does not say %q: %s", w, said)
for _, w := range []string{"mx", "my", "s.a", "s.b"} {
if !strings.Contains(got.CycleSaid, w) {
t.Errorf("the cycle does not say %q: %s", w, got.CycleSaid)
}
}
// Without the edges the same members are ordered by repository.
got = orderOf(members, reach, nil, nil)
if !reflect.DeepEqual(got.Order, []string{"x", "y"}) || len(got.Cycle) > 0 || len(got.Pairs) > 0 {
t.Fatalf("members with no edge: %v %v %+v", got.Order, got.Cycle, got.Pairs)
}
}
// novox/hq ADR 0239 decision 8: a walk waits for the delivery's owner only while the seat has a holder on