Files
mesh-delivery/modules/mesh-delivery-indexer/internal/index/toolchains_test.go
T
jschoubben 71a35a6376 MV1: move contracts and sdk under modules/, importers follow
The delivery repository's contracts and SDK live under modules/ as
mesh-delivery-contracts and mesh-delivery-sdk, their Go module paths
renamed to match the folders (the TypeScript names already did), and
every importer in mesh-delivery, mesh-catalog, mesh-host, mesh-tools
and mesh-sdk follows in the same group: a design as it should be, one
path per module, checked by the group's check building every importer.

Builds: design 47 G §427, §88

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:09:56 +01:00

846 lines
45 KiB
Go

package index
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
sdkstep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph"
"mesh-delivery-indexer/internal/fingerprint"
)
// The fixtures here are mesh-catalog a67f0196's manifests (testdata, captured from that commit) with the module renamed (and,
// where a test needs one, a build.on entry added as the controller's own test has it, standing_on_test.go:22): the manifest
// parser, not a hand-written manifest, reads them.
func renamed(t *testing.T, from, to string) string {
t.Helper()
raw := catalogueManifest(t, from)
out := strings.Replace(raw, `"module": "`+from+`"`, `"module": "`+to+`"`, 1)
if out == raw {
t.Fatalf("%s's manifest has no module line", from)
}
return strings.Replace(out, `"version": "1",`, `"version": "1.0",`, 1) // the validator reads MAJOR.MINOR only (design 47 G §368)
}
func standingOn(t *testing.T, raw, base string) string {
t.Helper()
out := strings.Replace(raw, `"build": {`, `"build": {
"on": [{"arg": "RUNTIME_BASE", "module": "`+base+`", "artifact": "runtime"}],`, 1)
if out == raw {
t.Fatal("the manifest has no build")
}
return out
}
const buildAgentRepo = "novox/mesh-controller"
// build-agent's trunk builds as `mesh-controller.builds` showed them on 2026-10-21 (ids, times and source fingerprints
// as listed; the repository is the clone URL the record names, the ref "main"; the inputs are the shape of aInputs, which
// the indexer does not read for this): the older one at 13:03 and the newer at 15:03 (issue 1727).
const (
agentSrcX = "src1:80e5fb129a98754a85fa3a43b1dded12dddedd25823d12dbe8783287faa0c59c"
agentSrcY = "src1:44ed217d74a7c7e38400bb0bf32358ed96502cb385d24f1435fe971ad44c7b88"
agentCloneURL = "http://novox.internal:20000/novox/mesh-controller.git"
)
func agentBuild(id, src, at string) [2]any {
when, _ := time.Parse(time.RFC3339, at)
raw, _ := builds.EncodeBuildLogEntry(builds.BuildLogEntry{Event: builds.BuildEndedEvent, Module: "build-agent", FP: src, Repository: agentCloneURL,
Directory: "modules/build-agent", Commit: "cada7e3c", Node: "n1", At: when, Outcome: builds.BuildDone})
return [2]any{"build-agent", raw}
}
type mod struct {
name, manifest string
}
// merged applies one merge of the catalogue carrying these modules (every one a Go bundle's go.sum, a TypeScript bundle's
// package-lock.json) and returns the indexer.
// graphOf is the graph node the indexer holds of module.
func graphOf(t *testing.T, ix *Indexer, module string) graph.Node {
t.Helper()
v, ok := ix.Graph.(*memStore).vals[module]
if !ok {
t.Fatalf("graph.%s is not held", module)
}
g, err := graph.Decode(v)
if err != nil {
t.Fatal(err)
}
return g
}
func newFormIndexer(t *testing.T, mods ...mod) (*Indexer, *memStore) {
t.Helper()
ix, store, f := newFormIndexerUnmerged(t, mods...)
if err := ix.Apply(context.Background(), f); err != nil {
t.Fatal(err)
}
// build-agent holds no fingerprint of today's form, as live (no merge of the controller is applied; issue 1727).
return ix, store
}
// newFormIndexerUnmerged is the indexer and the catalogue merge of these modules, not yet applied.
func newFormIndexerUnmerged(t *testing.T, mods ...mod) (*Indexer, *memStore, forgemsg.Forge) {
t.Helper()
var recs [][2]any
trees, manifests := map[string]string{}, map[string]string{}
locks := map[string]map[string]string{}
for i, m := range mods {
dir := "modules/" + m.name
recs = append(recs, rec(t, "build-"+m.name, m.name, catalogueRepo, dir, "main", "", "2026-10-19T09:00:00Z", aInputs))
trees[dir], manifests[dir] = "tree-"+m.name, m.manifest
locks[dir] = map[string]string{"go.sum": avahiGoSum, "package-lock.json": strings.Repeat("c", 63) + string(rune('a'+i))}
}
recs = append(recs, agentBuild("build-1792580585452696147", agentSrcX, "2026-10-21T13:03:00Z"))
ix, store, _ := manifestsIndexer(t, recs...)
return ix, store, withManifests(mergedMsg(catalogueRepo, trees), manifests, locks)
}
// toolchainValue is a toolchains value as the build module's writer encodes it (mesh-sdk go/delivery EncodeToolchain).
func toolchainValue(t *testing.T, language, version, recipe string) []byte {
t.Helper()
b, err := EncodeToolchain(nodebuildgo.Toolchain{Language: language, Version: version, Recipe: recipe, At: ptr(time.Now())})
if err != nil {
t.Fatal(err)
}
return b
}
func reportToolchain(t *testing.T, ix *Indexer, module, node, language, version string) {
t.Helper()
reportToolchainRecipe(t, ix, module, node, language, version, "1")
}
func reportToolchainRecipe(t *testing.T, ix *Indexer, module, node, language, version, recipe string) {
t.Helper()
key, err := ToolchainKey(node, language)
if err != nil {
t.Fatal(err)
}
if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, recipe), false); err != nil {
t.Fatal(err)
}
}
func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 1444/AC5
t.Run("the build module of a language is the holder of the seat whose acting rule builds it", theBuildModuleOfALanguageIsTheHolderOfItsBuildSeat)
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
if m, _ := moduleOf(t, mods, "avahi"); m.None2 != sdkstep.ToolchainNoReport {
t.Run("a recipe change builds a Go module at once and again once the agreement moves", aRecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves)
t.Fatalf("before: none2 = %q", m.None2)
}
// The release that last merged avahi: its source revision names avahi's commit, so Src (and FPSrc) are 7.
merged, _ := moduleOf(t, mods, "avahi")
src := srcT{Group: "g", Heads: map[string]string{merged.Repository: merged.Commit}}
if err := applyS(ix, context.Background(), 7, src); err != nil {
t.Fatal(err)
}
if m, _ := moduleOf(t, mods, "avahi"); m.Src != 7 || m.FpSrc != 7 {
t.Fatalf("before: src %d fp-src %d, want 7 and 7", m.Src, m.FpSrc)
}
if g := graphOf(t, ix, "avahi"); g.None != sdkstep.ToolchainNoReport || g.Toolchains != nil {
t.Errorf("before: graph.avahi = %+v, want no toolchain edge and the reason", g)
}
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1")
m, _ := moduleOf(t, mods, "avahi")
// L3 (ADR 0603 §2, §3): the agreement moving rewrites the module's toolchain edge, and again when it moves on.
if g := graphOf(t, ix, "avahi"); g.FP != m.Fp2 || g.Toolchains["go"].Version != "go1.27.1" || g.Toolchains["go"].Module != "mesh-build-go" {
t.Errorf("after: graph.avahi = %+v, want the toolchain edge of go1.27.1", g)
}
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.28.0")
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.28.0")
if g := graphOf(t, ix, "avahi"); g.Toolchains["go"].Version != "go1.28.0" {
t.Errorf("moved on: graph.avahi = %+v, want the toolchain edge of go1.28.0", g)
}
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1")
m, _ = moduleOf(t, mods, "avahi")
if m.None2 != "" || m.Fp2 == "" {
t.Errorf("none2 = %q fp2 = %q, want a fingerprint: no builder input is waited for (ADR 0530 §1)", m.None2, m.Fp2)
}
// ADR 0601 §1 (review round 2): a fingerprint moved with no merge has no release that carries it: fp-src 0, not known,
// though the source revision is still the release's at the module's own Src; a source revision heard again stamps nothing.
if m.FpSrc != 0 {
t.Errorf("a fingerprint moved by a recompute: fp-src %d, want 0", m.FpSrc)
}
if err := applyS(ix, context.Background(), 8, src); err != nil {
t.Fatal(err)
}
if again, _ := moduleOf(t, mods, "avahi"); again.FpSrc != 0 {
t.Errorf("a source revision stamped the recomputed fingerprint: fp-src %d", again.FpSrc)
}
}
// Design 47 *A module's registration* ("No part fixes a name") and *What ADR 0656 leaves to this design* (the indexer reads
// `acts` into the catalog graph): the build module of a language is the module claiming the seat whose acting rule is step
// build on it, whatever its name, its stream the one of its manifest carrying toolchain.reported; a language two seats build
// and a seat two modules claim give none, a holder with no such stream one with no stream, each said. The indexer takes a
// toolchain of that module only.
func theBuildModuleOfALanguageIsTheHolderOfItsBuildSeat(t *testing.T) {
defs := map[string]seatdef.Definition{
"node-build-go": {Seat: "node-build-go", Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: "go", Bound: "60m"}}},
"node-build-node": {Seat: "node-build-node", Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: "typescript"}}},
"node-build-image": {Seat: "node-build-image"},
"mesh-catalog": {Seat: "mesh-catalog", Acts: []seatdef.ActingRule{{Step: seatdef.StepTakeIn}, {Step: seatdef.StepBuild}}},
}
claiming := func(module, seat string, events ...string) catalogue.Manifest {
m := catalogue.Manifest{Module: module, Claims: []catalogue.Claim{{Name: seat}}}
if len(events) > 0 {
m.Streams = []catalogue.StreamDeclaration{{Name: "toolchains-" + module, Events: events}}
}
return m
}
by, problems := BuildModulesOf(defs, []catalogue.Manifest{claiming("go-builds", "node-build-go", "toolchain.**"),
claiming("ts-builds", "node-build-node", "step.ended"), claiming("avahi", "node-mdns")})
want := map[string]BuildModule{"go": {Language: "go", Seat: "node-build-go", Module: "go-builds", Stream: "TOOLCHAINS_GO_BUILDS"},
"typescript": {Language: "typescript", Seat: "node-build-node", Module: "ts-builds"}}
if !reflect.DeepEqual(by, want) || len(problems) != 1 || !strings.Contains(problems[0], "ts-builds, which builds typescript, declares no stream") {
t.Errorf("build modules %+v problems %q, want %+v and the stream ts-builds lacks said", by, problems, want)
}
twice := maps.Clone(defs)
twice["node-build-go-2"] = seatdef.Definition{Seat: "node-build-go-2", Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: "go"}}}
by, problems = BuildModulesOf(twice, []catalogue.Manifest{claiming("go-builds", "node-build-go"), claiming("other", "node-build-node"), claiming("third", "node-build-node")})
if len(by) != 0 || len(problems) != 2 || !strings.Contains(problems[0], "each build go") || !strings.Contains(problems[1], "[other third] each claim node-build-node") {
t.Errorf("two seats building go and two holders of node-build-node: %+v %q, want none, both said", by, problems)
}
// The indexer finds them among the manifests it holds, and a report of a module that holds no build seat is left out.
builder := `{"module":"go-builds","version":"1","claims":[{"name":"node-build-go"}],"streams":[{"name":"toolchains-go","events":["toolchain.**"]}]}`
ix, _ := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")}, mod{"go-builds", builder})
ix.UseBuildModules(nil)
got, err := ix.BuildModulesNow(context.Background(), defs)
if err != nil || !reflect.DeepEqual(got, map[string]BuildModule{"go": {Language: "go", Seat: "node-build-go", Module: "go-builds", Stream: "TOOLCHAINS_GO"}}) {
t.Fatalf("the held manifests give %+v (%v), want go-builds building go", got, err)
}
key, _ := ToolchainKey("novox", "go")
if ix.PutToolchain("mesh-build-go", key, toolchainValue(t, "go", "go1.27.1", "1"), false) {
t.Errorf("a toolchain of mesh-build-go, which holds no build seat here, was taken in")
}
if !ix.PutToolchain("go-builds", key, toolchainValue(t, "go", "go1.27.1", "1"), false) {
t.Errorf("a toolchain of go-builds, the holder of node-build-go, was not taken in")
}
}
// ADR 0603 §3 (D9): a source revision that changes the Go build module's recipe and a Go module builds the module at once, with
// the recipe the nodes run, and again once the agreement moves to the new one: the module never waits and never loses its
// fingerprint between the two, and its toolchain edge names the recipe each was computed with.
func aRecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves(t *testing.T) {
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
for _, node := range []string{"novox", "ace"} {
reportToolchain(t, ix, "mesh-build-go", node, "go", "go1.27.1")
}
first, _ := moduleOf(t, mods, "avahi")
if first.Fp2 == "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "1" {
t.Fatalf("with the running recipe: fp2 %q, graph %+v", first.Fp2, graphOf(t, ix, "avahi"))
}
// The release carrying the new recipe is merged, but the nodes still run the old one: one node reporting the new recipe is
// no strict majority, so the fingerprint stays, nothing waits.
reportToolchainRecipe(t, ix, "mesh-build-go", "novox", "go", "go1.27.1", "2")
if held, _ := moduleOf(t, mods, "avahi"); held.Fp2 != first.Fp2 || held.None2 != "" {
t.Errorf("one node on the new recipe: fp2 %q none2 %q, want the fingerprint of the running recipe, %q", held.Fp2, held.None2, first.Fp2)
}
reportToolchainRecipe(t, ix, "mesh-build-go", "ace", "go", "go1.27.1", "2")
again, _ := moduleOf(t, mods, "avahi")
if again.Fp2 == "" || again.Fp2 == first.Fp2 || again.None2 != "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "2" {
t.Errorf("the agreement moved: fp2 %q (was %q) none2 %q graph %+v, want a second fingerprint at recipe 2", again.Fp2, first.Fp2, again.None2, graphOf(t, ix, "avahi"))
}
}
func TestNodesReportingDifferentVersionsLeaveTheReasonNamingBoth(t *testing.T) { // 1444/AC6
// Both reports are read before the module's first fingerprint is computed (a fingerprint computed before they
// disagree is kept: TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith).
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
for node, version := range map[string]string{"novox": "go1.27.1", "ace": "go1.26.0"} {
key, _ := ToolchainKey(node, "go")
ix.PutToolchain("mesh-build-go", key, toolchainValue(t, "go", version, "1"), false)
}
if err := ix.Recompute(context.Background()); err != nil {
t.Fatal(err)
}
m, _ := moduleOf(t, mods, "avahi")
if !strings.Contains(m.None2, "builders disagree") || !strings.Contains(m.None2, "go1.27.1") || !strings.Contains(m.None2, "go1.26.0") {
t.Errorf("none2 = %q, want 'builders disagree' naming both versions", m.None2)
}
}
// builderModules are the builder, a base, a module on the base (named so that it is stored before its base) and a module
// whose base is a TypeScript module with no toolchain reported.
func builderModules(t *testing.T) []mod {
return []mod{
{"mesh-delivery-builder", renamed(t, "avahi", "mesh-delivery-builder")},
{"mesh-tools", renamed(t, "avahi", "mesh-tools")},
{"avahi", standingOn(t, catalogueManifest(t, "avahi"), "mesh-tools")},
{"other", standingOn(t, renamed(t, "avahi", "other"), "ts-base")},
{"ts-base", renamed(t, "audit-logger", "ts-base")},
}
}
func goInputs(t *testing.T, ix *Indexer, mods *memStore, module, builder string, bases ...string) fingerprint.Inputs {
t.Helper()
return fingerprint.Inputs{Module: module, Tree: "tree-" + module, Builder: builder, Bases: bases,
Locks: map[string]string{"tools": avahiGoSum},
Toolchains: map[string]meshcatalogclient.Toolchain2{"tools": {Language: "go", Version: "go1.27.1", Recipe: "1"}}}
}
func fp2(t *testing.T, in fingerprint.Inputs) string {
t.Helper()
fp, none := fingerprint.Of(in)
if none != "" {
t.Fatal(none)
}
return fp
}
// 1887/I1, ADR 0530 §1: bases are computed before the modules taking them, and no module's fingerprint takes the builder,
// the builder's own included: a trunk build of build-agent (or of the builder) moves nothing.
func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // 1444/AC7, AC8
ix, mods := newFormIndexer(t, builderModules(t)...)
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
builderFP := fp2(t, goInputs(t, ix, mods, "mesh-delivery-builder", ""))
toolsFP := fp2(t, goInputs(t, ix, mods, "mesh-tools", ""))
want := map[string]string{"mesh-delivery-builder": builderFP, "mesh-tools": toolsFP, "avahi": fp2(t, goInputs(t, ix, mods, "avahi", "", toolsFP))}
for name, fp := range want {
if m, _ := moduleOf(t, mods, name); m.Fp2 != fp || m.None2 != "" || m.Inputs2 == nil || m.Inputs2.Builder != "" {
t.Errorf("%s: fp2 = %q none2 = %q inputs %+v, want %s with no builder input", name, m.Fp2, m.None2, m.Inputs2, fp)
}
}
if m, _ := moduleOf(t, mods, "other"); m.Fp2 != "" || !strings.Contains(m.None2, "ts-base") || !strings.Contains(m.None2, "base") {
t.Errorf("other: fp2 = %q none2 = %q, want none2 naming the base ts-base", m.Fp2, m.None2)
}
// L3 (ADR 0603 §2): the module graph holds each module's edges as this recompute computed them.
goTool := map[string]graph.Toolchain{"go": {Module: "mesh-build-go", Version: "go1.27.1", Recipe: "1"}}
if g := graphOf(t, ix, "avahi"); g.FP != want["avahi"] || !reflect.DeepEqual(g.Bases, []string{"mesh-tools"}) ||
!reflect.DeepEqual(g.Toolchains, goTool) || g.Repository != catalogueRepo || g.Path != "modules/avahi" {
t.Errorf("graph.avahi = %+v", g)
}
if g := graphOf(t, ix, "other"); g.FP != "" || !strings.Contains(g.None, "ts-base") || !reflect.DeepEqual(g.Bases, []string{"ts-base"}) {
t.Errorf("graph.other = %+v, want its base and its reason", g)
}
// Milestone 47/G W4.1, W4.2 (design 47, Two graphs): the catalog graph entry of a module is its graph node with where the
// trunk holds it; a revision lists one entry per module by digest, an unchanged entry has the same digest in the next
// revision, and a changed one only its own.
entries := map[string]meshcatalog.CatalogEntry{}
for name := range mods.vals {
m, _ := moduleOf(t, mods, name)
entries[name] = CatalogEntryOf(name, m, catalogue.Manifest{}, "", graphOf(t, ix, name))
}
// W3.1's main: the Go main package the module's process runs, read from its manifest (here the indexer's own); a
// manifest that runs no process names none.
own, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
if m, err := catalogue.Decode(own); err != nil || MainOf(m) != "cmd/mesh-delivery-indexer" || MainOf(catalogue.Manifest{}) != "" {
t.Errorf("the indexer's main = %q (%v), want cmd/mesh-delivery-indexer", MainOf(m), err)
}
if e := entries["avahi"]; e.Fp != want["avahi"] || e.Commit == "" || e.ManifestDigest == "" || !reflect.DeepEqual(e.Bases, []string{"mesh-tools"}) ||
e.Toolchains["go"] != (meshcatalog.BuildEdge{Module: "mesh-build-go", Version: "go1.27.1", Recipe: "1"}) {
t.Errorf("the catalog entry of avahi = %+v", e)
}
// W3.1's inputs (the lead's ruling on finding 1): an entry carries what the next revision is computed from (the
// fingerprint's inputs, the toolchains used, the package versions), so the module's value read back from it is the
// value modules held, but for the old line's fields.
for name := range mods.vals {
m, _ := moduleOf(t, mods, name)
m.Fp, m.None, m.Needs = "", "", nil
if back := ModuleValueOf(entries[name]); !reflect.DeepEqual(back, m) {
t.Errorf("%s read back from its entry = %+v, want %+v", name, back, m)
}
}
if e := entries["avahi"]; e.Inputs == nil || e.Inputs.From == nil || e.Inputs.Toolchains["go"].Version != "go1.27.1" {
t.Errorf("the catalog entry of avahi carries inputs %+v, want what its fingerprint is computed from", e.Inputs)
}
rev, objects, err := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: 1, Kind: "trunk", Source: 7}, entries)
if err != nil || len(rev.Entries) != len(mods.vals) || len(objects) != len(mods.vals) || rev.V != CatalogEntryLayout {
t.Fatalf("revision %+v with %d objects (%v), want one entry per module of %d", rev, len(objects), err, len(mods.vals))
}
moved := maps.Clone(entries)
tools := moved["mesh-tools"]
tools.Fp = "src2:" + strings.Repeat("b", 64)
moved["mesh-tools"] = tools
next, _, _ := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: 2, Kind: "trunk", Source: 8}, moved)
for i, ref := range next.Entries {
if same := ref.Digest == rev.Entries[i].Digest; same == (ref.Module == "mesh-tools") {
t.Errorf("entry %s: digest %s against %s before; only mesh-tools' moves", ref.Module, ref.Digest, rev.Entries[i].Digest)
}
}
// W4.2's store: both revisions kept in catalog-revisions hold one object per entry once, the moved entry twice, and
// each list; a revision read back is its entries whole; an entry the bucket lost is an error, never left out.
kept := newMemObjects(0)
_, nextObjects, _ := CatalogRevisionOf(next, moved)
if err := KeepRevision(context.Background(), kept, rev, objects); err != nil {
t.Fatal(err)
}
if err := KeepRevision(context.Background(), kept, next, nextObjects); err != nil {
t.Fatal(err)
}
if names, _ := kept.Names(context.Background()); len(names) != len(mods.vals)+1+2 || kept.puts != len(names) {
t.Errorf("catalog-revisions holds %v after %d puts, want each entry once, the moved one twice, and two lists", names, kept.puts)
}
if back, got, err := ReadRevision(context.Background(), kept, "trunk.2"); err != nil || back.Source != 8 || len(got) != len(mods.vals) ||
!slices.ContainsFunc(got, func(e meshcatalog.CatalogEntry) bool { return e.Module == "mesh-tools" && e.Fp == tools.Fp }) {
t.Errorf("trunk.2 read back = %+v, %d entries, %v", back, len(got), err)
}
// W4.3's second part: the view of the trunk read at start is the newest trunk revision, each module's value as its
// entry gives it back; a bucket that keeps none is an empty view at 0.
view, nodes, at, err := TrunkView(context.Background(), kept)
if err != nil || at != 2 {
t.Fatalf("the view is read at %d (%v), want 2", at, err)
}
for name, e := range moved {
key, _ := meshcatalogclient.ModulesKey(name)
raw, _, found, _ := view.Get(context.Background(), key)
got, err := meshcatalogclient.DecodeModule(raw)
if want := ModuleValueOf(e); !found || err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("the view's %s = %+v (%v), want %+v", name, got, err, want)
}
// The node of the module graph the entry carries is read back with the view (NodeOf), so a revision kept before the
// first recompute after a start loses no edge.
gk, _ := graph.Key(name)
rawNode, _, found, _ := nodes.Get(context.Background(), gk)
if back, err := graph.Decode(rawNode); !found || err != nil || !reflect.DeepEqual(graph.NodeOf(e), back) || back.FP != e.Fp ||
!reflect.DeepEqual(back.Bases, e.Bases) || !reflect.DeepEqual(CatalogEntryOf(name, ModuleValueOf(e), catalogue.Manifest{}, "", back).Bases, e.Bases) {
t.Errorf("the view's node of %s = %+v (%v), want %+v", name, back, err, graph.NodeOf(e))
}
}
if empty, _, at, err := TrunkView(context.Background(), newMemObjects(0)); err != nil || at != 0 || func() bool { k, _ := empty.Keys(context.Background()); return len(k) != 0 }() {
t.Errorf("an empty bucket's view is read at %d (%v), want an empty view at 0", at, err)
}
_ = kept.Delete(context.Background(), EntryObject(next.Entries[0].Digest))
if _, _, err := ReadRevision(context.Background(), kept, "trunk.2"); err == nil || !strings.Contains(err.Error(), next.Entries[0].Module) {
t.Errorf("trunk.2 with a lost entry read without error naming it: %v", err)
}
// W4.3: a recompute keeps a trunk revision of the module graph and says catalog-revision; one that moves no entry keeps
// none; a module leaving makes the next revision, naming it changed.
ix.Revisions = newMemObjects(0)
var revisions []meshcatalog.CatalogRevisionEvent
ix.SayCatalogRevision = func(_ context.Context, e meshcatalog.CatalogRevisionEvent) error {
revisions = append(revisions, e)
return nil
}
if err := ix.Recompute(context.Background()); err != nil || len(revisions) != 1 || revisions[0].Revision != 1 || revisions[0].Kind != "trunk" ||
revisions[0].Entries != int64(len(mods.vals)) || len(revisions[0].Changed) != len(mods.vals) {
t.Fatalf("the first recompute said %+v (%v), want trunk revision 1 with every module", revisions, err)
}
graph := ix.Graph.(*memStore)
written := graph.writes
if err := ix.Recompute(context.Background()); err != nil || graph.writes != written || len(revisions) != 1 {
t.Errorf("a recompute that moves nothing wrote the graph %d times and said %d revisions (%v)", graph.writes-written, len(revisions), err)
}
// A module the modules state no longer holds (a merge dropped it, issue 1900) leaves the graph at the next recompute.
delete(mods.vals, "other")
if err := ix.Recompute(context.Background()); err != nil {
t.Fatal(err)
}
if len(revisions) != 2 || revisions[1].Revision != 2 || !reflect.DeepEqual(revisions[1].Changed, []string{"other"}) {
t.Errorf("after other left, said %+v, want revision 2 naming other changed", revisions)
}
if _, kept, err := ReadRevision(context.Background(), ix.Revisions, "trunk.2"); err != nil || len(kept) != len(mods.vals) {
t.Errorf("trunk.2 holds %d entries (%v), want %d", len(kept), err, len(mods.vals))
}
if _, held := graph.vals["other"]; held {
t.Error("graph.other is still held after its module went")
}
if _, held := graph.vals["avahi"]; !held {
t.Error("graph.avahi went with another module")
}
// Review finding 3 (ADR 0603 §2): a graph state not on the bus yet (process.ErrNotYet), or one whose listing fails, never
// fails a recompute; the first writes nothing and says so, the second skips the deletion pass and says so.
graph.vals["ghost"], graph.revs["ghost"] = []byte("x"), 1
var said []string
ix.Logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }
for _, row := range []struct {
name string
err error
say string
}{
{"not on the bus yet", process.ErrNotYet, "not on the bus yet"},
{"a listing that fails", errors.New("the bus is slow"), "are not deleted"},
} {
said, graph.keysErr = nil, row.err
if err := ix.Recompute(context.Background()); err != nil {
t.Errorf("%s: the recompute failed: %v", row.name, err)
}
if _, held := graph.vals["ghost"]; !held || !strings.Contains(strings.Join(said, "\n"), row.say) {
t.Errorf("%s: ghost held %v, log %q, want it kept and %q said", row.name, held, said, row.say)
}
}
graph.keysErr = nil
if err := ix.Recompute(context.Background()); err != nil {
t.Fatal(err)
}
if _, held := graph.vals["ghost"]; held {
t.Error("graph.ghost is still held once the graph can be listed")
}
a := agentBuild("build-1792587792086548572", agentSrcY, "2026-10-21T15:03:00Z")
_ = ix.Builds.Put(a[1].([]byte))
if err := ix.Recompute(context.Background()); err != nil {
t.Fatal(err)
}
for name, fp := range want {
if m, _ := moduleOf(t, mods, name); m.Fp2 != fp {
t.Errorf("%s moved with a build of build-agent: fp2 = %q, want %s", name, m.Fp2, fp)
}
}
// The lead's ruling A on milestone 47/G: a first start whose objects keep no trunk revision takes in the head the source
// names for every repository, by the forge bridge's merged message, and keeps revision 1 with every module; a message
// the bridge does not give keeps none, and the next source revision takes the trunk in again.
first, _, merged := newFormIndexerUnmerged(t, builderModules(t)...)
first.Revisions = newMemObjects(0)
var firstSaid []meshcatalog.CatalogRevisionEvent
first.SayCatalogRevision = func(_ context.Context, e meshcatalog.CatalogRevisionEvent) error {
firstSaid = append(firstSaid, e)
return nil
}
// Lead-47's ruling R1: the catch-up runs off the source handler, so a bridge that has not answered yet holds no source
// revision (the first message is answered only once ApplySource returned; held in the handler it is answered as failed).
answers, applied := 0, make(chan struct{})
first.Message = func(_ context.Context, repository, head string, _ int) ([]byte, error) {
if answers++; answers == 1 {
select {
case <-applied:
return nil, errors.New("the forge bridge does not answer")
case <-time.After(10 * time.Second):
return nil, errors.New("the catch-up held the source handler")
}
}
if repository != catalogueRepo || head != merged.Merge {
return nil, fmt.Errorf("no merge of %s at %s", repository, head)
}
return json.Marshal(merged)
}
heads := sourceOf(catalogueRepo, merged.Merge)
var firstLog []string
var logMu sync.Mutex
first.Logf = func(f string, a ...any) {
logMu.Lock()
firstLog = append(firstLog, fmt.Sprintf(f, a...))
logMu.Unlock()
}
err = applyS(first, context.Background(), 5, heads)
close(applied)
first.catchUpWG.Wait()
if err != nil || len(firstSaid) != 0 || !strings.Contains(strings.Join(firstLog, "\n"), "the forge bridge does not answer") {
t.Fatalf("a catch-up whose message is not read said %+v (%v), log %q, want no revision and the bridge's silence said", firstSaid, err, firstLog)
}
err = applyS(first, context.Background(), 6, heads)
first.catchUpWG.Wait()
if err != nil || len(firstSaid) != 1 || firstSaid[0].Revision != 1 ||
firstSaid[0].Entries != int64(len(builderModules(t))) || firstSaid[0].Source != 6 {
t.Fatalf("the first start's catch-up said %+v (%v), want trunk revision 1 of source 6 with every module", firstSaid, err)
}
// KeepView: a write to the view outside a recompute (here a verdict carried into one module) is the next revision.
viewKey, _ := meshcatalogclient.ModulesKey("avahi")
_ = write(context.Background(), first.Modules, viewKey, func(value []byte, _ bool) ([]byte, bool, error) {
m, _ := meshcatalogclient.DecodeModule(value)
m.Verdict, m.Problems = meshcatalogclient.VerdictRefused, []string{"a field the judge refuses"}
data, err := meshcatalogclient.EncodeModule(m)
return data, false, err
})
if err := first.KeepView(context.Background()); err != nil || len(firstSaid) != 2 || !reflect.DeepEqual(firstSaid[1].Changed, []string{"avahi"}) {
t.Errorf("KeepView after a write said %+v (%v), want revision 2 naming avahi", firstSaid, err)
}
if view, _, at, err := TrunkView(context.Background(), first.Revisions); err != nil || at != 2 {
t.Errorf("the view at a restart is read at %d (%v), want 2", at, err)
} else if raw, _, _, _ := view.Get(context.Background(), viewKey); !strings.Contains(string(raw), "a field the judge refuses") {
t.Errorf("the view at a restart lost the verdict: %s", raw)
}
// W4.14: the catch-up tool (indexer_take_in with no module and no repository, as the node agent calls it after a
// restore) takes in the head the source names for every repository again, whatever revision is kept, and keeps none
// when nothing moved; a repository whose message is not read is named in unread.
if key, value, err := NewCatchUp("", "node-engine@ace", time.Now()); err != nil || key != CatchUpKey {
t.Fatalf("NewCatchUp = %q (%v), want key %q", key, err, CatchUpKey)
} else if a, err := DecodeTakeIn(value); err != nil || !a.CatchUp {
t.Fatalf("a catch-up ask decodes to %+v (%v), want a catch-up", a, err)
}
asked := answers
if got := first.CatchUp(context.Background()); !got.Stored || answers != asked+1 || len(firstSaid) != 2 || len(got.Unread) != 0 {
t.Errorf("the catch-up answered %+v after %d asks, said %d revisions, want the one repository taken in, no new revision", got, answers-asked, len(firstSaid))
}
first.Message = func(context.Context, string, string, int) ([]byte, error) {
return nil, errors.New("the forge bridge does not answer")
}
if got := first.CatchUp(context.Background()); got.Stored || !reflect.DeepEqual(got.Unread, []string{catalogueRepo}) {
t.Errorf("a catch-up whose message is not read answered %+v, want %s unread", got, catalogueRepo)
}
// The tools bundle answers modules and graph from the newest trunk revision, their marks at its number; with none kept
// it says it waits (view-waits), never an empty catalog.
trunkStates := TrunkStates{Objects: first.Revisions, Under: func(string) views.Source { return nil }}
if keys, err := trunkStates.State("modules").Keys(); err != nil || len(keys) != len(builderModules(t)) {
t.Errorf("the bundle's modules = %v (%v), want every module of revision 2", keys, err)
}
if e, ok, err := trunkStates.State(views.MarksState).Get("graph"); err != nil || !ok || !strings.Contains(string(e.Value), `"revision":2`) {
t.Errorf("the bundle's mark of graph = %+v %v (%v), want revision 2", e, ok, err)
}
if _, err := (TrunkStates{Objects: newMemObjects(0)}).State("graph").Keys(); !errors.Is(err, ErrNoRevision) {
t.Errorf("the bundle's graph with no revision kept answered %v, want it to wait", err)
}
// W4.4, ADR 0663: the seat mesh-catalog's verb revision answers, from the indexer's own objects, exactly the parts whose
// entry in the newest trunk revision declares it, the holder refused too; a candidate's by its group and heads; none
// kept, everyone waits.
verbObjects := newMemObjects(0)
keep := func(rev meshcatalog.CatalogRevision, declares map[string]bool) {
entries := map[string][]byte{}
for _, name := range []string{"avahi", Module, "mesh-grants", "node-tools"} {
e := meshcatalog.CatalogEntry{Module: name, Repository: catalogueRepo}
if declares[name] {
e.Invokes = []string{RevisionInvoke}
}
data, _ := json.Marshal(e)
digest := fmt.Sprintf("%s-%d-%s", rev.Kind, rev.Revision, name)
entries[digest] = data
rev.Entries = append(rev.Entries, meshcatalog.EntryRef{Module: name, Digest: digest})
}
if err := KeepRevision(context.Background(), verbObjects, rev, entries); err != nil {
t.Fatal(err)
}
}
if got, err := Revision(context.Background(), verbObjects, Module, "n1.node-tools", meshcatalog.RevisionInput{}); err != nil || !got.NoneYet || got.Revision != nil || len(got.Entries) != 0 {
t.Errorf("revision with none kept answered %+v, %v, want none-yet alone with no error (design 47 G §530)", got, err)
}
keep(meshcatalog.CatalogRevision{Revision: 3, Kind: "trunk"}, map[string]bool{"node-tools": true, Module: true})
keep(meshcatalog.CatalogRevision{Revision: 4, Kind: "candidate", Group: "feat/x", Heads: "0123456789ab"}, map[string]bool{"node-tools": true, "mesh-grants": true})
for caller, answered := range map[string]bool{"n1.node-tools": true, "n1.mesh-grants": false, "n1." + Module: false, "person.someone": false, "unproven": false} {
got, err := Revision(context.Background(), verbObjects, Module, caller, meshcatalog.RevisionInput{})
if (err == nil) != answered || (answered && (got.Revision.Revision != 3 || len(got.Entries) != 4 || got.Next != "")) {
t.Errorf("revision to %s answered %+v (%v), want answered %v with trunk revision 3 whole", caller, got, err, answered)
}
}
if got, err := Revision(context.Background(), verbObjects, Module, "n1.node-tools", meshcatalog.RevisionInput{Group: "feat/x", Heads: "0123456789ab", After: "mesh-grants"}); err != nil ||
got.Revision.Kind != "candidate" || len(got.Entries) != 1 || got.Entries[0].Module != "node-tools" {
t.Errorf("a candidate's revision after mesh-grants answered %+v (%v), want its one entry node-tools", got, err)
}
if _, err := Revision(context.Background(), verbObjects, Module, "n1.node-tools", meshcatalog.RevisionInput{Revision: 9}); err == nil {
t.Error("a trunk revision not kept was answered")
}
// ADR 0614 §2, §3: the graph names what each module invokes and serves, and leaves out, over the whole graph, the
// verb edges of two modules calling each other, from either side; a third calling one of them keeps its edge.
invoking := func(name, invokes string) mod {
raw := renamed(t, "avahi", name)
return mod{name, strings.Replace(raw, `"version": "1.0",`, `"version": "1.0", "invokes": [`+invokes+`],`, 1)}
}
vx, _ := newFormIndexer(t, invoking("caller", `"callee.avahi_status"`), invoking("callee", `"caller.avahi_browse", "*"`),
invoking("third", `"callee.avahi_status"`))
for name, want := range map[string][2][]string{
"caller": {{"callee.avahi_status"}, {"callee.avahi_status"}},
"callee": {{"caller.avahi_browse"}, {"caller.avahi_browse"}},
"third": {{"callee.avahi_status"}, nil},
} {
if g := graphOf(t, vx, name); !reflect.DeepEqual(g.Invokes, want[0]) || !reflect.DeepEqual(g.LeftOut, want[1]) ||
!slices.Contains(g.Serves, name+".avahi_status") {
t.Errorf("graph.%s = %+v, want it to invoke %v and leave out %v", name, g, want[0], want[1])
}
}
}
func TestABuildHeardBeforeAnyToolchainIsReadDropsNoFingerprint(t *testing.T) { // 1727 (review: a restart flapped every FP2)
ix, mods := newFormIndexer(t, builderModules(t)...)
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
before, _ := moduleOf(t, mods, "mesh-tools")
if before.Fp2 == "" {
t.Fatalf("mesh-tools has no fp2 to begin with: %+v", before)
}
// a restarted indexer has read no toolchains bucket yet
ix.mu.Lock()
ix.toolchains = nil
ix.mu.Unlock()
if err := ix.RecomputeAtStart(context.Background()); err != nil {
t.Fatal(err)
}
if m, _ := moduleOf(t, mods, "mesh-tools"); m.Fp2 != before.Fp2 || m.None2 != "" {
t.Errorf("mesh-tools: fp2 = %q none2 = %q, want fp2 %q kept", m.Fp2, m.None2, before.Fp2)
}
}
func TestAToolchainEntryChangedToTheSameValuesWritesNothing(t *testing.T) { // 1444/AC9; 1966: nothing moved writes nothing
ctx := context.Background()
for _, c := range []struct {
name string
do func(t *testing.T, ix *Indexer, mods *memStore)
}{
{"the same version and recipe, a later time", func(t *testing.T, ix *Indexer, _ *memStore) {
time.Sleep(2 * time.Millisecond)
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
}},
// Issue 1966 (2026-11-08): the package versions compared by reflect.DeepEqual across two map types never matched, so
// every recompute wrote every module again with the bytes it held, each write a views event.
{"a recompute with nothing moved", func(t *testing.T, ix *Indexer, _ *memStore) {
if err := ix.Recompute(ctx); err != nil {
t.Fatal(err)
}
}},
// write itself: an answer whose bytes are the bytes held is no write, whoever computed it.
{"a compute answering the bytes held", func(t *testing.T, ix *Indexer, mods *memStore) {
for _, key := range slices.Sorted(maps.Keys(mods.vals)) {
if err := write(ctx, ix.Modules, key, func(value []byte, _ bool) ([]byte, bool, error) { return slices.Clone(value), false, nil }); err != nil {
t.Fatal(err)
}
}
}},
} {
t.Run(c.name, func(t *testing.T) {
ix, mods := newFormIndexer(t, builderModules(t)...)
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
before := mods.writes
c.do(t, ix, mods)
if mods.writes != before {
t.Errorf("modules written %d times, want none", mods.writes-before)
}
})
}
}
func TestModulesThatStandOnEachOtherAreNamedAsACycle(t *testing.T) { // 1444 (the indexer has no graph: a cycle is a reason)
ix, mods := newFormIndexer(t,
mod{"aa", standingOn(t, renamed(t, "avahi", "aa"), "bb")},
mod{"bb", standingOn(t, renamed(t, "avahi", "bb"), "aa")})
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
for _, name := range []string{"aa", "bb"} {
if m, _ := moduleOf(t, mods, name); m.Fp2 != "" || !strings.Contains(m.None2, "cycle") {
t.Errorf("%s: fp2 = %q none2 = %q, want a reason naming the cycle", name, m.Fp2, m.None2)
}
}
}
func TestAMergeWritesEachModuleOnceWithItsFinalValueAndAReplayWritesNothing(t *testing.T) { // 1444 (review finding 8)
ctx := context.Background()
ix, mods, f := newFormIndexerUnmerged(t, mod{"avahi", catalogueManifest(t, "avahi")})
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") // agreed before the merge is heard
if err := ix.Apply(ctx, f); err != nil {
t.Fatal(err)
}
m, _ := moduleOf(t, mods, "avahi")
if mods.writes != 1 || m.Fp2 == "" {
t.Errorf("writes = %d fp2 = %q after one merge, want one write with its fingerprint", mods.writes, m.Fp2)
}
if err := ix.Apply(ctx, f); err != nil {
t.Fatal(err)
}
if mods.writes != 1 {
t.Errorf("writes = %d after the same merge again, want still 1", mods.writes)
}
}
func TestAMergeKeepsAFingerprintTheModuleStillHas(t *testing.T) { // 1444 (review finding 8: a merge never blanks FP2)
ctx := context.Background()
ix, mods := newFormIndexer(t, builderModules(t)...)
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
before, _ := moduleOf(t, mods, "mesh-tools")
if before.Fp2 == "" {
t.Fatalf("mesh-tools has no fp2: %+v", before)
}
w := mods.writes
// the same catalogue merged again at a later commit: nothing moved
_, _, f := newFormIndexerUnmerged(t, builderModules(t)...)
f.Merge = otherMerge
if err := ix.Apply(ctx, f); err != nil {
t.Fatal(err)
}
// and the commit stays the one the fingerprint first appeared at (the operator, 2026-10-25)
if after, _ := moduleOf(t, mods, "mesh-tools"); after.Fp2 != before.Fp2 || after.Commit != before.Commit || mods.writes != w {
t.Errorf("fp2 = %q at %.8s (was %q at %.8s), writes %d more", after.Fp2, after.Commit, before.Fp2, before.Commit, mods.writes-w)
}
}
func TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith(t *testing.T) { // 1615/AC3
ix, mods := newFormIndexer(t, builderModules(t)...)
if m, _ := moduleOf(t, mods, "avahi"); m.Toolchains != nil {
t.Fatalf("before a toolchain is reported: toolchains = %+v, want none", m.Toolchains)
}
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
m, _ := moduleOf(t, mods, "avahi")
want := map[string]meshcatalog.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}}
if m.Fp2 == "" || !reflect.DeepEqual(m.Toolchains, want) {
t.Fatalf("avahi: fp2 %q toolchains %+v, want %+v", m.Fp2, m.Toolchains, want)
}
if o, _ := moduleOf(t, mods, "other"); o.Fp2 != "" || o.Toolchains != nil {
t.Errorf("a module with no fingerprint carries toolchains %+v", o.Toolchains)
}
// An upgrade moves the fingerprint and the toolchains together, with no merge.
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.2")
n, _ := moduleOf(t, mods, "avahi")
if n.Fp2 == m.Fp2 || n.Toolchains["go"].Version != "go1.27.2" {
t.Errorf("after the upgrade: fp2 %q (was %q) toolchains %+v, want the new version with the new fingerprint", n.Fp2, m.Fp2, n.Toolchains)
}
// Issue 1585 (ADR 0573 §2): a module whose go.sum names mesh-sdk takes mesh-sdk's latest published version into its
// fingerprint and keeps it beside its toolchains; a mesh-sdk trunk merge moves it with no merge of its own, and a module
// using no package is not moved. With no published version held, it has none, naming the package.
const sdk = "git.novox.be/novox/mesh-sdk/go"
_, _, uses := newFormIndexerUnmerged(t, builderModules(t)...)
uses.Packages = map[string][]string{"modules/avahi": {sdk}}
if err := ix.Apply(context.Background(), uses); err != nil {
t.Fatal(err)
}
if u, _ := moduleOf(t, mods, "avahi"); u.Fp2 != "" || u.None2 != fingerprint.NoPackageVersion+sdk {
t.Errorf("no published mesh-sdk: fp2 %q none2 %q", u.Fp2, u.None2)
}
tools, _ := moduleOf(t, mods, "mesh-tools")
sdkMerge := func(commit, tree string) meshcatalog.ModulesByModuleValue {
t.Helper()
f := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 3, Head: h1,
Merge: commit, Root: tree, Trees: map[string]string{"": tree}, Manifests: map[string]string{"": `{"module":"mesh-sdk","version":"1.0"}`}}
if err := ix.Apply(context.Background(), f); err != nil {
t.Fatal(err)
}
a, _ := moduleOf(t, mods, "avahi")
if a.Fp2 == "" || !reflect.DeepEqual(a.Packages, map[string]string{sdk: commit}) {
t.Fatalf("after mesh-sdk's merge %.8s: fp2 %q none2 %q packages %v", commit, a.Fp2, a.None2, a.Packages)
}
if o, _ := moduleOf(t, mods, "mesh-tools"); o.Fp2 != tools.Fp2 || o.Packages != nil {
t.Errorf("mesh-tools uses no package and moved: fp2 %q (was %q) packages %v", o.Fp2, tools.Fp2, o.Packages)
}
return a
}
first := sdkMerge(strings.Repeat("1", 40), "tree-sdk-1")
second := sdkMerge(strings.Repeat("2", 40), "tree-sdk-2")
if second.Fp2 == first.Fp2 {
t.Errorf("a mesh-sdk trunk merge kept avahi's fingerprint %q", first.Fp2)
}
n = second
// Nodes that disagree keep the fingerprint already computed, with its toolchains (issue 1940, 2026-10-25 20:56: a
// stale report blanked every module and the controller's take-in refused modules already built); only a new
// fingerprint needs the agreement: a merge that moves the module's tree gives it none, naming the disagreement.
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.26.0")
if d, _ := moduleOf(t, mods, "avahi"); d.Fp2 != n.Fp2 || !reflect.DeepEqual(d.Toolchains, n.Toolchains) || d.None2 != "" {
t.Errorf("disagreeing nodes: fp2 %q toolchains %+v none2 %q, want the fingerprint kept (%q) with %+v", d.Fp2, d.Toolchains, d.None2, n.Fp2, n.Toolchains)
}
_, _, f := newFormIndexerUnmerged(t, builderModules(t)...)
f.Trees["modules/avahi"] = "tree-avahi-moved"
if err := ix.Apply(context.Background(), f); err != nil {
t.Fatal(err)
}
if d, _ := moduleOf(t, mods, "avahi"); d.Fp2 != "" || d.Toolchains != nil || !strings.HasPrefix(d.None2, "builders disagree") {
t.Errorf("a new tree while nodes disagree: fp2 %q toolchains %+v none2 %q, want no fingerprint and the disagreement", d.Fp2, d.Toolchains, d.None2)
}
// They agree again: the new tree gets its fingerprint.
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.2")
if a, _ := moduleOf(t, mods, "avahi"); a.Fp2 == "" || a.Fp2 == n.Fp2 {
t.Errorf("agreed again: fp2 %q (was %q), want a new fingerprint", a.Fp2, n.Fp2)
}
}