Merge D9, D13b, D15 and review round 1 findings 3 and 4: the indexer runs without its graph state, a package inside a module's directory has its publisher (ADR 0603 §3, §4, §10; ADR 0604 §1)

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
This commit is contained in:
2026-11-03 17:21:28 +01:00
11 changed files with 335 additions and 15 deletions
@@ -27,6 +27,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/provisions"
"mesh-delivery-assigner/internal/judge"
"mesh-delivery-assigner/internal/assign"
@@ -434,6 +435,60 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
if !self {
t.Error("ace's own want does not say it composes its own once placed")
}
// ADR 0603 (L3, D15): one source revision changes a provider (db), its new consumer (app) and a build module (tool, whose
// recipe moved: ace runs its first build and the second, under the agreed toolchain, is the one the source revision names).
// The keys are written as their writers write them: the indexer's modules and manifests, the builder's builds, the
// controller's grants, the placement seat's placement and the provisioner's provisions. The build module's want moves at
// once and the provider's with it; the consumer's waits for its provision, with the words naming it, and moves once the
// provisioner provides it (rule 8, ADR 0570 §4): nothing waits for a turn or an order, only for a key.
offer := `"provides":[{"name":"database","scope":"mesh","answered-by":"helper"}]`
dbDigest := b.manifest(t, "db", `{"module":"db","version":"1",`+offer+`}`)
appDigest := b.manifest(t, "app", `{"module":"app","version":"1","requires":["database"]}`)
toolDigest := b.manifest(t, "tool", `{"module":"tool","version":"1"}`)
fpOf := func(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
toolOld := fpOf('1')
for module, row := range map[string]struct {
fp, digest string
}{"db": {fpOf('2'), dbDigest}, "app": {fpOf('3'), appDigest}, "tool": {fpOf('4'), toolDigest}} {
mod, _ := delivery.EncodeModule(delivery.Module{FP2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: delivery.RootPath})
b.putJSON(t, delivery.ModulesBucket, module, mod)
fps := []string{row.fp}
if module == "tool" {
fps = append(fps, toolOld)
}
for _, fp := range fps {
built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: row.digest})
bkey, _ := delivery.BuilderBuildKey(module, fp)
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
gkey, _ := delivery.PartGrantKey(module, fp)
grant, _ := delivery.EncodeGrant(delivery.Grant{})
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
}
b.place(t, "ace", module)
}
runsOld, _ := delivery.EncodeHave(delivery.Have{FP: toolOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
b.putJSON(t, delivery.HaveBucket, "ace.tool", runsOld)
asked := func(state, why string) {
raw, err := provisions.Encode(provisions.Provision{V: provisions.Version, Provision: "database", Consumer: provisions.End{Module: "app", Node: "ace"},
Provider: provisions.End{Module: "db", Node: "ace"}, State: state, Why: why, At: time.Now()})
if err != nil {
t.Fatal(err)
}
b.putJSON(t, provisions.ProvisionsBucket, "ace.app.database", raw)
}
asked(provisions.Asked, "the provider's build is not done")
if w := b.wantNow(t, "ace", "tool", "the build module's want at the second build", func(w delivery.Want) bool { return w.FP == fpOf('4') }); w.FP != fpOf('4') {
t.Errorf("tool's want = %+v", w)
}
b.wantNow(t, "ace", "db", "the provider's want at its fingerprint", func(w delivery.Want) bool { return w.FP == fpOf('2') })
if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, "want.ace.app"); err == nil {
t.Errorf("app was wanted while its provision was asked: %s", e.Value())
}
asked(provisions.Provided, "")
if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w delivery.Want) bool { return w.FP == fpOf('3') }); w.FP != fpOf('3') {
t.Errorf("app's want = %+v", w)
}
}
// 1297/B9 (the assigner's half, ADR 0528 clause 6): started whole, the assigner holds a fingerprint whose health check
@@ -589,6 +589,45 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt
}
}
// ADR 0603 §3 (D9): a source revision changing the Go build module's recipe and a Go module builds the module at once with
// the recipe the nodes run and again, a second key under the new fingerprint, once the agreement moves to the new recipe;
// the first key is neither rebuilt nor waited on.
func TestARecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint(t *testing.T) {
r := newRig(t, goBundle)
r.consider(t, "modules/m")
calls := len(r.bm.calls)
fp2 := "src2:" + hex64('2')
for _, n := range []string{"ace", "novox"} {
b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "2", At: time.Now()})
if err != nil {
t.Fatal(err)
}
r.tools.Put(buildrun.GoBuildModule, n+".go", b, false)
}
r.b.Reconsider(context.Background())
r.b.Wait()
if len(r.bm.calls) != calls {
t.Errorf("%d calls, was %d: the first fingerprint was built again for the new recipe", len(r.bm.calls), calls)
}
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), FP2: fp2,
Manifest: mdigest('a'), Path: "modules/m", Toolchains: map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "2"}}})
if err != nil {
t.Fatal(err)
}
r.b.Module(context.Background(), "m", data)
r.b.Wait()
second, err := delivery.BuilderBuildKey("m", fp2)
if err != nil {
t.Fatal(err)
}
if v := valueOf(t, r.st, second); v.State != delivery.BuildDone || v.Toolchains["go"].Recipe != "2" {
t.Errorf("second build = %+v, want done with recipe 2", v)
}
if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone {
t.Errorf("first build = %+v, want it left done", v)
}
}
func TestNodesTheyDisagreeOnLeaveTheKeyUnwritten(t *testing.T) { // 1615/AC4
r := newRig(t, goBundle)
r.tools.Put(buildrun.GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false)
@@ -0,0 +1,66 @@
package check
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
// A change to a build module or a base is built first, and what stands on it is composed from the manifests (novox/hq ADR
// 0603 §10): the check builds the build module (a candidate's mesh-build-go judges its own change, ADR 0535) and the bases,
// and composes the rest from their manifests, because a module built with the new recipe is built once the agreement
// moves, a second time (ADR 0603 §3). The verdict names the changed modules whose fingerprint names the build module or
// the base the change alters, as warnings of its composition: words for the operator, no part decides on them.
// buildChangeWarnings are the composition warnings of a change that alters the Go build module, or a module other changed
// modules are built on: one line each, naming those modules, sorted. None for a change that alters neither, or when the
// manifests cannot be read (nothing is said that was not read).
func (st *state) buildChangeWarnings(ctx context.Context) []string {
changed := map[string]catalogue.Manifest{}
for _, p := range st.c.Pulls {
for module, cm := range p.Fps {
if cm.FP2 == "" || cm.Manifest == "" || st.j.Read == nil {
continue
}
m, err := st.j.Read.Manifest(ctx, cm.Manifest)
if err != nil {
st.j.logf("%s: the manifest of %s cannot be read, so the modules built with a changed build module or base are not named: %v", st.c.Key, module, err)
return nil
}
changed[module] = m
}
}
var out []string
for _, t := range st.targets {
if t.module != buildrun.GoBuildModule || !changesBuildGo(t.pull.Repository, t.changed) {
continue
}
var built []string
for module, m := range changed {
if m.Build != nil && slices.ContainsFunc(m.Build.Artifacts, func(a catalogue.Artifact) bool { return a.Language == "go" }) {
built = append(built, module)
}
}
slices.Sort(built)
if len(built) > 0 {
out = append(out, fmt.Sprintf("the change alters the build module %s: %s, built with it, are composed from their manifests and built again once the agreed toolchain moves (ADR 0603 §10)",
buildrun.GoBuildModule, strings.Join(built, ", ")))
}
break
}
g := graph.OfManifests(changed)
for _, base := range slices.Sorted(maps.Keys(changed)) {
if on := g.Dependents(base); len(on) > 0 {
out = append(out, fmt.Sprintf("the change alters %s, a base of %s: they are composed from their manifests and built after it (ADR 0603 §10)",
base, strings.Join(on, ", ")))
}
}
return out
}
@@ -3,9 +3,12 @@ package check
import (
"context"
"errors"
"maps"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -99,3 +102,51 @@ func TestACompositionNotHadIsNotRun(t *testing.T) {
type fixedTrunks struct{ heads map[string]string }
func (f fixedTrunks) Heads() map[string]string { return f.heads }
// ADR 0603 §10: a change that alters the Go build module, or a module others stand on, names in the composition's warnings
// (and so the check's line) the changed modules built with it, from their manifests; a change that alters neither says nothing.
func TestAChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) {
dig := func(c string) string { return strings.Repeat(c, 64) }
goBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "bin", Language: "go"}}}
for _, row := range []struct {
name string
changed []string
manifest map[string]catalogue.Manifest
want []string
}{
{name: "the build module changes", changed: []string{buildGoFile},
manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "mail": {Module: "mail", Build: goBuild}, "docs": {Module: "docs"}},
want: []string{"mesh-build-go: mail, web, built with it"}},
{name: "a base changes", changed: []string{"README.md"},
manifest: map[string]catalogue.Manifest{"image": {Module: "image"},
"web": {Module: "web", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "image", Artifact: "img"}}}}},
want: []string{"alters image, a base of web"}},
{name: "neither changes", changed: []string{"README.md"},
manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}},
} {
b := candidateBench(t, true)
b.fetch.Changed = row.changed
b.ex.Compositions = &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"g14"}}}
read, fps := fixedManifests{}, map[string]delivery.CandidateModule{}
for module, m := range row.manifest {
d := dig(string(rune('a' + len(read))))
read[d], fps[module] = m, delivery.CandidateModule{FP2: "src2:" + dig("f"), Manifest: d}
}
b.ex.Read = read
p := pullOf(catalogueRepository, 9, head1)
p.Fps, p.Modules = fps, slices.Sorted(maps.Keys(fps))
v := b.judge(t, caseOf(t, p)).Verdict
if v.State != delivery.VerdictPass || v.Composition == nil {
t.Fatalf("%s: %+v", row.name, v)
}
words := compositionWords(v.Composition, v.Took.Composition)
for _, w := range row.want {
if !strings.Contains(words, w) {
t.Errorf("%s: the line says %q, want it to name %q", row.name, words, w)
}
}
if row.want == nil && v.Composition.Words != "" {
t.Errorf("%s: warned %q though neither a build module nor a base changed", row.name, v.Composition.Words)
}
}
}
@@ -287,6 +287,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: "not made: " + err.Error()}
return
}
comp.Warnings = append(comp.Warnings, st.buildChangeWarnings(ctx)...) // 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)
@@ -2,12 +2,14 @@ package index
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
@@ -60,11 +62,18 @@ func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) graph.
}
// writeGraph writes the graph node of every module of edges whose value changed, at most GraphMost of them, then
// deletes each graph key whose module the modules state no longer holds. Nil Graph writes nothing.
// deletes each graph key whose module the modules state no longer holds. Nil Graph writes nothing, and neither does a
// graph state that is not on the bus yet (process.ErrNotYet; main.go: until then no graph is written), said in the log;
// a listing that fails otherwise skips the deletion pass, said, and never fails the merge or the recompute that wrote.
func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges) error {
if ix.Graph == nil {
return nil
}
keys, listErr := ix.Graph.Keys(ctx)
if errors.Is(listErr, process.ErrNotYet) {
ix.logf("the module graph is not written: its state is not on the bus yet")
return nil
}
names := slices.Sorted(maps.Keys(edges))
if len(names) > GraphMost {
ix.logf("the module graph holds at most %d modules: %d are known, so %s and the %d after it are not written",
@@ -104,19 +113,21 @@ func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges)
}
}
held, err := ix.Modules.Keys(ctx)
if err != nil {
return fmt.Errorf("listing the modules for the graph: %w", err)
}
keys, err := ix.Graph.Keys(ctx)
if err != nil {
return fmt.Errorf("listing the graph: %w", err)
switch {
case listErr != nil:
ix.logf("the graph keys of modules the modules state no longer holds are not deleted: listing the graph: %v", listErr)
return nil
case err != nil:
ix.logf("the graph keys of modules the modules state no longer holds are not deleted: listing the modules: %v", err)
return nil
}
for _, key := range keys {
if slices.Contains(held, key) {
continue
}
if err := write(ctx, ix.Graph, key, func(_ []byte, found bool) ([]byte, bool, error) { return nil, found, nil }); err != nil {
return fmt.Errorf("graph.%s: %w", key, err)
ix.logf("graph.%s is not deleted: %v", key, err)
continue
}
ix.logf("graph.%s deleted: the modules state holds no module %s", key, key)
}
@@ -19,6 +19,7 @@ const (
// key under the indexer; unbound, when set, is every read's error (a state not yet on the bus).
type memStore struct {
unbound error
keysErr error // the listing's error
vals map[string][]byte
revs map[string]uint64
next uint64
@@ -36,6 +37,9 @@ func (m *memStore) Get(_ context.Context, key string) ([]byte, uint64, bool, err
return v, m.revs[key], ok, nil
}
func (m *memStore) Keys(context.Context) ([]string, error) {
if m.keysErr != nil {
return nil, m.keysErr
}
var keys []string
for k := range m.vals {
keys = append(keys, k)
@@ -2,12 +2,15 @@ package index
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/sdk/graph"
@@ -114,12 +117,17 @@ func toolchainValue(t *testing.T, language, version, recipe string) []byte {
}
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 := delivery.ToolchainKey(node, language)
if err != nil {
t.Fatal(err)
}
if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, "1"), false); err != nil {
if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, recipe), false); err != nil {
t.Fatal(err)
}
}
@@ -172,6 +180,31 @@ func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 14
}
}
// 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 TestARecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves(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).
@@ -258,6 +291,34 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { //
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 {
@@ -76,6 +76,11 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) {
if j, err := ix.JudgeAll(ctx, nil); err != nil || j.Written != 0 || len(said) != 1 {
t.Fatalf("a judge that judges alike: %+v %v, said %d", j, err, len(said))
}
// ADR 0603 §4 (D9): a source revision that changes the indexer builds the new indexer, which at its start judges every
// manifest it holds again, none of them fresh: all of them judged, nothing written for a verdict that stands.
if j, err := ix.JudgeAtStart(ctx); err != nil || j.Judged != 2 || j.Written != 0 || len(said) != 1 {
t.Fatalf("the new indexer at its start: %+v %v, said %d, want both manifests judged and nothing written", j, err, len(said))
}
// The manifest mended: its new digest is accepted and the module's refusal goes.
mended := hold("bad", `{"module":"bad","version":"2"}`)
if j, err := ix.JudgeAll(ctx, map[string]bool{mended: true}); err != nil || j.Written != 1 || len(j.Refused) != 0 {
+17 -4
View File
@@ -230,8 +230,9 @@ func (g Graph) Requirers(provision string) []string {
return out
}
// Publisher is the module of the graph whose repository and directory publish the mesh package pkg (PackageRepository),
// or "" when the graph holds none.
// Publisher is the module of the graph whose repository and directory publish the mesh package pkg (PackageRepository):
// the module whose directory is the package's directory or the nearest one above it (the longest match, a package
// published from inside a module's directory belonging to that module), or "" when the graph holds none.
func (g Graph) Publisher(pkg string) string {
repository, dir, ok := delivery.PackageRepository(pkg)
if !ok {
@@ -240,12 +241,24 @@ func (g Graph) Publisher(pkg string) string {
if dir == "" {
dir = delivery.RootPath
}
best, bestLen := "", -1
for _, name := range slices.Sorted(maps.Keys(g)) {
if n := g[name]; n.Repository == repository && n.Path == dir {
n := g[name]
if n.Repository != repository {
continue
}
switch {
case n.Path == dir:
return name
case n.Path == delivery.RootPath:
if bestLen < 0 {
best, bestLen = name, 0
}
case strings.HasPrefix(dir, n.Path+"/") && len(n.Path) > bestLen:
best, bestLen = name, len(n.Path)
}
}
return ""
return best
}
// buildEdges are the modules n's build edges reach: its bases, the publisher of each of its packages, and the build
+16 -2
View File
@@ -32,7 +32,11 @@ func TestGraphEdges(t *testing.T) {
n.Bases, n.Packages, n.Toolchains = []string{"base"}, []string{"git.novox.be/novox/lib"}, gotool
}),
"top": node("novox/mesh-catalog", "modules/top", func(n *Node) { n.Bases = []string{"app"} }),
"db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }),
// A package published from inside a module's directory belongs to that module (ADR 0604 §1): users imports build-go's.
"users": node("novox/other", "modules/users", func(n *Node) {
n.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun"}
}),
"db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }),
"web": node("novox/mesh-catalog", "modules/web", func(n *Node) {
n.Requires, n.Awaits = []string{"database", "db"}, []string{"database"}
}),
@@ -40,7 +44,7 @@ func TestGraphEdges(t *testing.T) {
for module, want := range map[string][]string{
"base": {"app", "top"},
"lib": {"app", "top"},
"build-go": {"app", "top"}, // build-go's own toolchain edge reaches itself, which is left out
"build-go": {"app", "top", "users"}, // build-go's own toolchain edge reaches itself, which is left out
"top": nil,
"db": nil, // a provision edge is no build edge
} {
@@ -63,6 +67,16 @@ func TestGraphEdges(t *testing.T) {
if got := g.Requirers("db"); !reflect.DeepEqual(got, []string{"web"}) {
t.Errorf("requirers of db = %v", got)
}
for pkg, want := range map[string]string{
"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun": "build-go", // inside its directory
"git.novox.be/novox/mesh-catalog/modules/build-go": "build-go", // its directory
"git.novox.be/novox/mesh-catalog/modules/build-gone": "", // beside it, not inside
"git.novox.be/novox/lib/sub": "lib", // a root module publishes what is below it
} {
if got := g.Publisher(pkg); got != want {
t.Errorf("publisher of %s = %q, want %q", pkg, got, want)
}
}
if g.Publisher("git.novox.be/novox/lib") != "lib" || g.Publisher("git.novox.be/novox/mesh-sdk/go") != "" {
t.Error("a package's publisher is not the module at its repository and directory")
}