diff --git a/modules/mesh-delivery-checker/internal/check/graph_cycle.go b/modules/mesh-delivery-checker/internal/check/graph_cycle.go index 0490f9a9..673ff548 100644 --- a/modules/mesh-delivery-checker/internal/check/graph_cycle.go +++ b/modules/mesh-delivery-checker/internal/check/graph_cycle.go @@ -10,6 +10,8 @@ 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" ) // The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each @@ -49,15 +51,15 @@ func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict { if err != nil { return fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error()) } - cycle := delivery.ManifestGraph(change).Cycle() + cycle := graph.OfManifests(change).Cycle() if cycle == nil { st.c.note("the change's module graph has no cycle") return nil } words := strings.Join(cycle, " -> ") if before, err := read(req.Cases[1].Manifests); err == nil { - among := delivery.Graph{} - for name, n := range delivery.ManifestGraph(before) { + among := graph.Graph{} + for name, n := range graph.OfManifests(before) { if slices.Contains(cycle, name) { among[name] = n } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go index 24411bac..62df17fb 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go @@ -7,6 +7,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/graph" + "mesh-delivery-indexer/internal/index" ) @@ -35,7 +37,7 @@ func CatalogTools(c index.CatalogRead) []stdio.Tool { } // catalogRead reads the indexer's modules, manifests and graph states for the seat's verbs. -func catalogRead(modules, manifests, graph stdio.KeptState) index.CatalogRead { +func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead { return index.CatalogRead{ Modules: func() (map[string]delivery.Module, error) { keys, err := modules.Keys() @@ -72,21 +74,21 @@ func catalogRead(modules, manifests, graph stdio.KeptState) index.CatalogRead { kept, err := delivery.DecodeManifest(e.Value) return kept.JSON, err }, - Graph: func() (delivery.Graph, error) { - keys, err := graph.Keys() + Graph: func() (graph.Graph, error) { + keys, err := nodes.Keys() if err != nil { return nil, err } - g := delivery.Graph{} + g := graph.Graph{} for _, k := range keys { - e, err := graph.Get(k) + e, err := nodes.Get(k) if err != nil { return nil, fmt.Errorf("graph.%s: %w", k, err) } if e == nil { continue } - if n, err := delivery.DecodeGraphNode(e.Value); err == nil { + if n, err := graph.Decode(e.Value); err == nil { g[k] = n } } @@ -97,5 +99,5 @@ func catalogRead(modules, manifests, graph stdio.KeptState) index.CatalogRead { // catalogTools are the seat's verbs over the indexer's own states, as main serves them once the indexer claims the seat. func catalogTools() []stdio.Tool { - return CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(delivery.GraphState))) + return CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State))) } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go index eb111122..0d896889 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -26,6 +26,8 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/process" "mesh-delivery-indexer/internal/index" @@ -300,7 +302,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // the judge refuses no use of an old section. oldSections := process.AwaitBucket(ctx, js, conn, index.OldSectionsBucket, "the count of the old sections is not kept, so the judge refuses no use of one", logger.Printf) // The module graph (ADR 0603 §2): a state this delivery brings, bound when it appears; until then no graph is written. - graph := process.AwaitBucket(ctx, js, conn, delivery.GraphBucket, "no module graph is written", logger.Printf) + graph := process.AwaitBucket(ctx, js, conn, graph.Bucket, "no module graph is written", logger.Printf) ix := &index.Indexer{OldSections: awaitedStore{oldSections}, Store: kvStore{candidates}, Modules: kvStore{modules}, Manifests: kvStore{manifests}, Taken: awaitedStore{taken}, Builds: known, Verdicts: awaitedStore{verdicts}, SayRefused: sayRefused(js, cred, logger.Printf), SayAccepted: sayAccepted(js, cred, logger.Printf), Predefined: awaitedStore{predefined}, ConfigCandidates: awaitedStore{configCandidates}, Logf: logger.Printf, diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index e0cba4fe..b1620ac0 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -67,7 +67,7 @@ type Indexer struct { // Packages is the packages state (delivery.PackagesBucket, packages.go; novox/hq ADR 0593 §2); nil publishes none, // and every fingerprint takes the version line. Packages Store - // Graph is the module graph's state (delivery.GraphBucket, graph.go; novox/hq ADR 0603 §2); nil writes no graph. + // Graph is the module graph's state (graph.Bucket, graph.go; novox/hq ADR 0603 §2); nil writes no graph. Graph Store // Files asks the forge for every blob under each directory at a commit, by directory then path relative to it, each // with its git object id (gitea.gitea_commit_trees with files): the listing a package's digest is computed from. diff --git a/modules/mesh-delivery-indexer/internal/index/catalog.go b/modules/mesh-delivery-indexer/internal/index/catalog.go index 0d5c30ce..33807d48 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog.go @@ -9,13 +9,15 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" ) // The seat mesh-catalog's verbs, answered from the indexer's own states (novox/hq ADR 0604 §1, §2): modules, module, // provides and dependents read the modules state, the manifests state and the module graph, and write nothing. The // indexer serves no request subject (ADR 0437 §4): its tools bundle serves them as .. Each answer keeps the // field names of the old catalog module's tool it replaces (catalog_modules, catalog_module, catalog_provides, -// catalog_dependents), so a caller that moves to the seat reads the same fields. +// catalog_dependents): the answers are the seat's contract (catalogue.CatalogModules and the rest), built here. // CatalogRead reads the states the seat's verbs answer from. type CatalogRead struct { @@ -24,99 +26,75 @@ type CatalogRead struct { // Manifest is the manifest kept under a digest in the manifests state, as its JSON. Manifest func(digest string) (string, error) // Graph is the module graph, by module. - Graph func() (delivery.Graph, error) + Graph func() (graph.Graph, error) } -// CatalogEntry is one module of the catalog: its repository, its directory there (delivery.RootPath for the root), the -// merge commit it was last changed at, and its fingerprint or why it has none. -type CatalogEntry struct { - Module string `json:"module"` - Repository string `json:"repository"` - Path string `json:"path"` - Commit string `json:"commit"` - FP string `json:"fp,omitempty"` - None string `json:"none,omitempty"` -} - -// CatalogModule is one module whole: its entry and its manifest as the trunk holds it. -type CatalogModule struct { - CatalogEntry - Manifest json.RawMessage `json:"manifest"` -} - -// CatalogEnd is a module that provides, requires or depends on something, with its commit; Scope is a provider's scope. -type CatalogEnd struct { - Module string `json:"module"` - Commit string `json:"commit"` - Scope string `json:"scope,omitempty"` -} - -func entryOf(name string, m delivery.Module) CatalogEntry { - return CatalogEntry{Module: name, Repository: m.Repository, Path: m.Path, Commit: m.Commit, FP: m.FP2, None: m.None2} +func entryOf(name string, m delivery.Module) catalogue.CatalogEntry { + return catalogue.CatalogEntry{Module: name, Repository: m.Repository, Path: m.Path, Commit: m.Commit, FP: m.FP2, None: m.None2} } // CatalogModulesAnswer is the verb modules: every module the modules state holds, by name. -func (c CatalogRead) CatalogModulesAnswer() (map[string]any, error) { +func (c CatalogRead) CatalogModulesAnswer() (catalogue.CatalogModules, error) { mods, err := c.Modules() if err != nil { - return nil, fmt.Errorf("the indexer's modules cannot be read: %w", err) + return catalogue.CatalogModules{}, fmt.Errorf("the indexer's modules cannot be read: %w", err) } - out := []CatalogEntry{} + out := []catalogue.CatalogEntry{} for _, name := range slices.Sorted(maps.Keys(mods)) { out = append(out, entryOf(name, mods[name])) } - return map[string]any{"modules": out}, nil + return catalogue.CatalogModules{Modules: out}, nil } // CatalogModuleAnswer is the verb module: one module whole, its manifest included. -func (c CatalogRead) CatalogModuleAnswer(name string) (map[string]any, error) { +func (c CatalogRead) CatalogModuleAnswer(name string) (catalogue.CatalogOne, error) { name = strings.TrimSpace(name) if name == "" { - return nil, fmt.Errorf("name the module") + return catalogue.CatalogOne{}, fmt.Errorf("name the module") } mods, err := c.Modules() if err != nil { - return nil, fmt.Errorf("the indexer's modules cannot be read: %w", err) + return catalogue.CatalogOne{}, fmt.Errorf("the indexer's modules cannot be read: %w", err) } m, ok := mods[name] if !ok { - return nil, fmt.Errorf("the trunk holds no module %s", name) + return catalogue.CatalogOne{}, fmt.Errorf("the trunk holds no module %s", name) } - one := CatalogModule{CatalogEntry: entryOf(name, m)} + one := catalogue.CatalogModule{CatalogEntry: entryOf(name, m)} if m.Manifest == "" { - return nil, fmt.Errorf("the indexer keeps no manifest of %s", name) + return catalogue.CatalogOne{}, fmt.Errorf("the indexer keeps no manifest of %s", name) } raw, err := c.Manifest(m.Manifest) if err != nil { - return nil, fmt.Errorf("the manifest %s of %s cannot be read: %w", m.Manifest, name, err) + return catalogue.CatalogOne{}, fmt.Errorf("the manifest %s of %s cannot be read: %w", m.Manifest, name, err) } one.Manifest = json.RawMessage(raw) - return map[string]any{"module": one}, nil + return catalogue.CatalogOne{Module: one}, nil } // CatalogProvidesAnswer is the verb provides: the modules that provide a provision, each with its scope (a module provides // its own name, at node scope), and the modules that require it, from the module graph's provision edges. -func (c CatalogRead) CatalogProvidesAnswer(provision string) (map[string]any, error) { +func (c CatalogRead) CatalogProvidesAnswer(provision string) (catalogue.CatalogProvides, error) { provision = strings.TrimSpace(provision) if provision == "" { - return nil, fmt.Errorf("name the provision") + return catalogue.CatalogProvides{}, fmt.Errorf("name the provision") } g, mods, err := c.graphAndModules() if err != nil { - return nil, err + return catalogue.CatalogProvides{}, err } - provides, requires := []CatalogEnd{}, []CatalogEnd{} + provides, requires := []catalogue.CatalogEnd{}, []catalogue.CatalogEnd{} for _, name := range g.Providers(provision) { - end := CatalogEnd{Module: name, Commit: mods[name].Commit, Scope: catalogue.ScopeNode} + end := catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit, Scope: catalogue.ScopeNode} if name != provision { end.Scope = c.scopeOf(mods[name], provision) } provides = append(provides, end) } for _, name := range g.Requirers(provision) { - requires = append(requires, CatalogEnd{Module: name, Commit: mods[name].Commit}) + requires = append(requires, catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit}) } - return map[string]any{"provision": provision, "provides": provides, "requires": requires}, nil + return catalogue.CatalogProvides{Provision: provision, Provides: provides, Requires: requires}, nil } // scopeOf is the scope the module's manifest offers provision at, "" when its manifest cannot be read. @@ -142,23 +120,23 @@ func (c CatalogRead) scopeOf(m delivery.Module, provision string) string { // CatalogDependentsAnswer is the verb dependents: the modules whose build edges reach the module, directly or through // others, from the module graph. -func (c CatalogRead) CatalogDependentsAnswer(module string) (map[string]any, error) { +func (c CatalogRead) CatalogDependentsAnswer(module string) (catalogue.CatalogDependents, error) { module = strings.TrimSpace(module) if module == "" { - return nil, fmt.Errorf("name the module") + return catalogue.CatalogDependents{}, fmt.Errorf("name the module") } g, mods, err := c.graphAndModules() if err != nil { - return nil, err + return catalogue.CatalogDependents{}, err } - out := []CatalogEnd{} + out := []catalogue.CatalogEnd{} for _, name := range g.Dependents(module) { - out = append(out, CatalogEnd{Module: name, Commit: mods[name].Commit}) + out = append(out, catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit}) } - return map[string]any{"module": module, "dependents": out}, nil + return catalogue.CatalogDependents{Module: module, Dependents: out}, nil } -func (c CatalogRead) graphAndModules() (delivery.Graph, map[string]delivery.Module, error) { +func (c CatalogRead) graphAndModules() (graph.Graph, map[string]delivery.Module, error) { g, err := c.Graph() if err != nil { return nil, nil, fmt.Errorf("the module graph cannot be read: %w", err) diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_test.go index 6cec8c2a..03c0b68c 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_test.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_test.go @@ -11,6 +11,8 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" ) // capturedKeys are the field names of the first object at path (a list's first element when the value is a list) in the @@ -67,15 +69,15 @@ func catalogVerbsAnswerFromTheStates(t *testing.T) { raw, _, err := ix.manifestRaw(context.Background(), digest) return raw, err }, - Graph: func() (delivery.Graph, error) { - g := delivery.Graph{} + Graph: func() (graph.Graph, error) { + g := graph.Graph{} for k := range ix.Graph.(*memStore).vals { g[k] = graphOf(t, ix, k) } return g, nil }, } - answer := func(v map[string]any, err error) []byte { + answer := func(v any, err error) []byte { t.Helper() if err != nil { t.Fatal(err) diff --git a/modules/mesh-delivery-indexer/internal/index/graph.go b/modules/mesh-delivery-indexer/internal/index/graph.go index deaac724..f9195d3b 100644 --- a/modules/mesh-delivery-indexer/internal/index/graph.go +++ b/modules/mesh-delivery-indexer/internal/index/graph.go @@ -8,10 +8,12 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" ) // The module graph (novox/hq ADR 0603 §2): at every recompute the indexer writes, in its state graph -// (delivery.GraphBucket), one key per module it holds the inputs of, its build and provision edges as that recompute +// (graph.Bucket), one key per module it holds the inputs of, its build and provision edges as that recompute // computed them, and deletes the key of a module the modules state no longer holds. A key is written only when its value // changes, so its history of 5 is five changes of the module's edges. The delivery view, the checker and the seat // mesh-catalog's verbs read it; nothing stores an order (ADR 0603 §1). @@ -33,11 +35,11 @@ type graphEdges struct { // graphNode is the graph node of e: its edges, the awaited provisions judged against providers (catalogue.Awaited, as // the assigner judges them). -func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) delivery.GraphNode { +func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) graph.Node { if e.fp == "" && e.none == "" { e.none = "its fingerprint was not computed" } - n := delivery.GraphEdgesOf(e.manifest, providers) + n := graph.EdgesOf(e.manifest, providers) n.FP, n.None, n.Repository, n.Path = e.fp, e.none, e.cur.Repository, e.cur.Path n.Packages = slices.Sorted(maps.Keys(e.pkgs)) tcs := e.tcs @@ -50,9 +52,9 @@ func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) delive continue } if n.Toolchains == nil { - n.Toolchains = map[string]delivery.GraphToolchain{} + n.Toolchains = map[string]graph.Toolchain{} } - n.Toolchains[language] = delivery.GraphToolchain{Module: builder, Version: t.Version, Recipe: t.Recipe} + n.Toolchains[language] = graph.Toolchain{Module: builder, Version: t.Version, Recipe: t.Recipe} } return n } @@ -79,7 +81,7 @@ func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges) return out } for _, name := range names { - key, err := delivery.GraphKey(name) + key, err := graph.Key(name) if err != nil { ix.logf("graph.%s is not written: %v", name, err) continue @@ -87,11 +89,11 @@ func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges) next := graphNode(edges[name], providers) err = write(ctx, ix.Graph, key, func(value []byte, found bool) ([]byte, bool, error) { if found { - if cur, err := delivery.DecodeGraphNode(value); err == nil && delivery.SameGraphNode(cur, next) { + if cur, err := graph.Decode(value); err == nil && graph.Same(cur, next) { return nil, false, nil } } - data, err := delivery.EncodeGraphNode(next) + data, err := graph.Encode(next) if err != nil { return nil, false, fmt.Errorf("graph.%s: %w", key, err) } diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go index b9bd4efc..0fbe30d6 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go @@ -9,6 +9,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/graph" + "mesh-delivery-indexer/internal/fingerprint" ) @@ -61,13 +63,13 @@ type mod struct { // 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) delivery.GraphNode { +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 := delivery.DecodeGraphNode(v) + g, err := graph.Decode(v) if err != nil { t.Fatal(err) } @@ -232,7 +234,7 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // 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]delivery.GraphToolchain{"go": {Module: "mesh-build-go", Version: "go1.27.1", Recipe: "1"}} + 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) diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index c77c7b80..7569b44c 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -13,6 +13,8 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" ) // The controller's manifest reader is mesh-controller internal/catalogue (Manifest, manifest.go: Consumes :521, @@ -91,7 +93,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build } // 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard. if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "modules", History: 20}, {Name: "predefined", History: 5}, {Name: "config-candidates", History: 5}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}, {Name: "verdicts", History: 5}, {Name: "oldsections", History: 1}, {Name: delivery.PackagesState, History: delivery.PackagesHistory}, - {Name: delivery.GraphState, History: delivery.GraphHistory}}; !reflect.DeepEqual(m.State, want) { + {Name: graph.State, History: graph.History}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } if want := []string{"mesh-build-go.toolchains", "mesh-build-node.toolchains", "mesh-delivery-assigner.placement", "mesh-delivery-builder.build-log", "mesh-delivery-checker.previews", "mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) { diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index 69e67c73..d147a802 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -13,6 +13,8 @@ import ( "mesh-delivery-view/internal/view" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/provisions" stdio "git.novox.be/novox/mesh-sdk/go" @@ -318,7 +320,7 @@ func main() { PredefinedState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerPredefinedState)), ConfigCandidatesState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState)), PackagesState: read(stdio.State(delivery.IndexerModule + "." + delivery.PackagesState)), - GraphState: read(stdio.State(delivery.GraphRead)), SourceState: read(stdio.State(delivery.GateModule + ".source")), + GraphState: read(stdio.State(graph.Read)), SourceState: read(stdio.State(delivery.GateModule + ".source")), ProvisionsState: read(stdio.State(provisions.ProvisionsRead)), BuildLog: stdio.Log(delivery.BuildLogRead).Since} u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go index 815e1407..6236315b 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go @@ -10,6 +10,8 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/provisions" "mesh-delivery-view/internal/view" @@ -34,8 +36,8 @@ func tiersToolSaysEachModulesWait(t *testing.T) { b, err := delivery.EncodeModule(delivery.Module{FP2: f, Manifest: manifest, Src: src, FPSrc: src, Repository: "novox/mesh-catalog", Commit: "c"}) return encoded(t, b, err) } - node := func(g delivery.GraphNode) string { - b, err := delivery.EncodeGraphNode(g) + node := func(g graph.Node) string { + b, err := graph.Encode(g) return encoded(t, b, err) } build := func(b delivery.BuilderBuild) string { @@ -59,9 +61,9 @@ func tiersToolSaysEachModulesWait(t *testing.T) { since := time.Date(2026, 11, 3, 16, 40, 0, 0, time.UTC) r := fake{"db": mod(fp("a"), 9), "web": mod(fp("b"), 9), "lib": mod(fp("c"), 9), "old": mod(fp("e"), 5)}.reader() r.GraphState = fake{ - "db": node(delivery.GraphNode{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}), - "web": node(delivery.GraphNode{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database"}, Awaits: []string{"database"}}), - "lib": node(delivery.GraphNode{FP: fp("c"), Repository: "novox/mesh-catalog"}), + "db": node(graph.Node{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}), + "web": node(graph.Node{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database"}, Awaits: []string{"database"}}), + "lib": node(graph.Node{FP: fp("c"), Repository: "novox/mesh-catalog"}), }.reader().State r.Builds = fake{ key(delivery.BuilderBuildKey("db", fp("a"))): build(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: manifest}), diff --git a/modules/mesh-delivery-view/internal/view/read.go b/modules/mesh-delivery-view/internal/view/read.go index 89d80210..5da2b0bb 100644 --- a/modules/mesh-delivery-view/internal/view/read.go +++ b/modules/mesh-delivery-view/internal/view/read.go @@ -55,7 +55,7 @@ type Reader struct { PredefinedState, ConfigCandidatesState StateRead // PackagesState is the indexer's published packages (delivery.PackagesBucket, ADR 0593 §2); unset reads none. PackagesState StateRead - // GraphState is the indexer's module graph (delivery.GraphBucket, ADR 0603 §2), SourceState the gate's source and + // GraphState is the indexer's module graph (graph.Bucket, ADR 0603 §2), SourceState the gate's source and // ProvisionsState the provisioner's keys: what view_tiers reads (tiers.go). Unset reads none, said in the answer. GraphState, SourceState, ProvisionsState StateRead // BuildLog reads the builder's log from a time (stdio.Log(delivery.BuildLogRead).Since); unset reads none. diff --git a/modules/mesh-delivery-view/internal/view/tiers.go b/modules/mesh-delivery-view/internal/view/tiers.go index 6289d96f..8055ad38 100644 --- a/modules/mesh-delivery-view/internal/view/tiers.go +++ b/modules/mesh-delivery-view/internal/view/tiers.go @@ -10,6 +10,8 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/provisions" ) @@ -93,13 +95,13 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) { len(names), TiersMost, names[TiersMost], len(names)-TiersMost-1) names = names[:TiersMost] } - graph := delivery.Graph{} + gr := graph.Graph{} if raws, err := values(r.GraphState); err != nil { out.Graph = "the module graph cannot be read, so no module is ordered after another: " + err.Error() } else { for key, raw := range raws { - if n, err := delivery.DecodeGraphNode(raw); err == nil { - graph[key] = n + if n, err := graph.Decode(raw); err == nil { + gr[key] = n } } } @@ -115,15 +117,15 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) { } after := map[string][]string{} for _, n := range names { - for _, b := range graph.Before(n) { + for _, b := range gr.Before(n) { if in[b] { after[n] = append(after[n], b) } } } - sub := delivery.Graph{} + sub := graph.Graph{} for _, n := range names { - if g, ok := graph[n]; ok { + if g, ok := gr[n]; ok { sub[n] = g } } @@ -168,7 +170,7 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) { } m := moved[n] tm := TierModule{Module: n, FP: m.FP2, None: m.None2, After: after[n]} - tm.Waits = r.waitsOf(n, m, graph[n], wants, provided, perr, away) + tm.Waits = r.waitsOf(n, m, gr[n], wants, provided, perr, away) out.Tiers[t] = append(out.Tiers[t], tm) } compact := out.Tiers[:0] @@ -202,7 +204,7 @@ func (r Reader) away() map[string]bool { // waitsOf is what module m waits on now: its build at its fingerprint, then on each node present that wants it, its want, // its have, and each provision its graph node awaits. -func (r Reader) waitsOf(module string, m delivery.Module, g delivery.GraphNode, wants, provided map[string]json.RawMessage, +func (r Reader) waitsOf(module string, m delivery.Module, g graph.Node, wants, provided map[string]json.RawMessage, perr error, away map[string]bool) []TierWait { var out []TierWait if m.FP2 == "" { diff --git a/sdk/go.mod b/sdk/go.mod new file mode 100644 index 00000000..31746712 --- /dev/null +++ b/sdk/go.mod @@ -0,0 +1,5 @@ +module git.novox.be/novox/mesh-delivery/sdk + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b diff --git a/sdk/go.sum b/sdk/go.sum new file mode 100644 index 00000000..cea3187b --- /dev/null +++ b/sdk/go.sum @@ -0,0 +1,14 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b h1:UPkJqQj+1y6WraOxDidkrokNjA25jxmCk+zIRkujjMc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/sdk/graph/graph.go b/sdk/graph/graph.go new file mode 100644 index 00000000..6648ced8 --- /dev/null +++ b/sdk/graph/graph.go @@ -0,0 +1,398 @@ +package graph + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "reflect" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Version is the version of a node's value this package writes and the only one it reads. +const Version = 1 + +// ErrUnknownVersion is what Decode returns for a value whose version is missing or not Version. +var ErrUnknownVersion = errors.New("graph: unknown value version") + +func decode(data []byte, into *Node) error { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return fmt.Errorf("graph: value does not fit its shape: %w", err) + } + if probe.V == nil || *probe.V != Version { + return fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version) + } + if err := json.Unmarshal(data, into); err != nil { + return fmt.Errorf("graph: value does not fit its shape: %w", err) + } + return nil +} + +// checkName refuses a module name a key cannot hold (delivery.ModulesKey's rule). +func checkName(kind, name string) error { + if _, err := delivery.ModulesKey(name); err != nil { + return fmt.Errorf("graph: the %s name %q: %w", kind, name, err) + } + return nil +} + +// checkRepository refuses a repository that is not /. +func checkRepository(repository string) error { + owner, name, ok := strings.Cut(repository, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return fmt.Errorf("graph: the repository %q is not /", repository) + } + return nil +} + +// The module graph (novox/hq ADR 0603 §2, ADR 0604 §1): the indexer keeps, as its state `graph`, one key per module it +// holds the inputs of, written at every recompute and deleted with the module. A key holds the module's build edges +// (its bases, the mesh packages its fingerprint takes, and per language the build module, toolchain version and recipe +// version its fingerprint names, ADR 0530 §1) and its provision edges (what its manifest provides, requires, and of what +// it requires what a want of it waits for). Nothing stores an order: a reader works it out from the edges (ADR 0603 +// §1, §6). The indexer writes it and works the edges out (EdgesOf), the delivery view, the checker and the indexer's +// answers of the seat mesh-catalog read it; all three are in this repository, which uses this package by path (the +// operator, 2026-11-03: no graph-related code in mesh-sdk, only contracts). It names no module. + +const ( + // State is the name of the indexer's state of the module graph, as its manifest declares it, and Bucket its + // bucket: one key per module (Key). + State = "graph" + Bucket = delivery.IndexerModule + "_" + State + // Read is the line a reader's manifest names in `reads` to read the graph. + Read = delivery.IndexerModule + "." + State + // History is the revisions the graph state keeps per key (ADR 0603 §2). + History = 5 +) + +// Key is the key, in Bucket, of a module: ``, as ModulesKey. +func Key(module string) (string, error) { return delivery.ModulesKey(module) } + +// ParseKey is the inverse of Key. +func ParseKey(key string) (string, error) { + module, err := Key(key) + if err != nil { + return "", fmt.Errorf("graph: %q is not a graph key: %w", key, err) + } + return module, nil +} + +// Node is the value of a graph key: one module's edges as its last recompute computed them. FP is its new-form +// fingerprint then, or None why it has none (exactly one is set); Repository and Path say where it is (as Module), so a +// package edge is resolved to the module that publishes it (GraphPublisher). Bases are the modules it is built on; +// Packages the mesh packages (Go module paths) its fingerprint took (Module.Packages); Toolchains, by language, the +// build module and the toolchain it was computed with. Provides are the provisions its manifest offers (its own name is +// provided too and is not listed), Requires what it requires and does not require optionally, and Awaits those of +// Requires a want of it waits for (catalogue.Awaited, ADR 0570 §4): the provision edges that order a source revision. +// Every list is sorted and each entry once; an encode sorts them. +type Node struct { + V int `json:"v"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` + Repository string `json:"repository"` + Path string `json:"path,omitempty"` + Bases []string `json:"bases,omitempty"` + Packages []string `json:"packages,omitempty"` + Toolchains map[string]Toolchain `json:"toolchains,omitempty"` + Provides []string `json:"provides,omitempty"` + Requires []string `json:"requires,omitempty"` + Awaits []string `json:"awaits,omitempty"` +} + +// Toolchain is a build edge of one language: the build module that builds it (Module) and the toolchain version +// and recipe version its fingerprint names. +type Toolchain struct { + Module string `json:"module"` + Version string `json:"version"` + Recipe string `json:"recipe"` +} + +func (g Node) check() error { + switch { + case (g.FP == "") == (g.None == ""): + return fmt.Errorf("graph: a graph node has either a fingerprint or a reason for none, not both or neither") + case g.FP != "" && !strings.HasPrefix(g.FP, delivery.SourcePrefix2): + return fmt.Errorf("graph: the graph node's fingerprint %q does not start with %s", g.FP, delivery.SourcePrefix2) + } + if err := checkRepository(g.Repository); err != nil { + return err + } + for _, b := range g.Bases { + if err := checkName("base", b); err != nil { + return err + } + } + for _, p := range g.Packages { + if _, _, ok := delivery.PackageRepository(p); !ok { + return fmt.Errorf("graph: the graph node's package %q is no mesh package's path", p) + } + } + for language, t := range g.Toolchains { + if err := checkName("build module", t.Module); err != nil { + return fmt.Errorf("graph: the graph node's toolchain of %q: %w", language, err) + } + if language == "" || t.Version == "" || t.Recipe == "" { + return fmt.Errorf("graph: the graph node's toolchain of %q needs its language, version and recipe version", language) + } + } + for _, list := range [][]string{g.Provides, g.Requires, g.Awaits} { + if slices.Contains(list, "") { + return fmt.Errorf("graph: a graph node names an empty provision") + } + } + for _, a := range g.Awaits { + if !slices.Contains(g.Requires, a) { + return fmt.Errorf("graph: the graph node awaits %q, which it does not require", a) + } + } + return nil +} + +func sortedOnce(in []string) []string { + if len(in) == 0 { + return nil + } + out := slices.Clone(in) + slices.Sort(out) + return slices.Compact(out) +} + +func (g Node) normal() Node { + g.Bases, g.Packages = sortedOnce(g.Bases), sortedOnce(g.Packages) + g.Provides, g.Requires, g.Awaits = sortedOnce(g.Provides), sortedOnce(g.Requires), sortedOnce(g.Awaits) + if len(g.Toolchains) == 0 { + g.Toolchains = nil + } + return g +} + +// Encode is g as JSON at Version, its lists sorted; it refuses what Node's contract does not hold. +func Encode(g Node) ([]byte, error) { + g = g.normal() + if err := g.check(); err != nil { + return nil, err + } + g.V = Version + return json.Marshal(g) +} + +// Decode reads a graph node; it refuses an unknown version (ErrUnknownVersion) and what Encode refuses. +func Decode(data []byte) (Node, error) { + var g Node + if err := decode(data, &g); err != nil { + return Node{}, err + } + if err := g.check(); err != nil { + return Node{}, err + } + return g.normal(), nil +} + +// Same says whether two graph nodes are the same value once sorted, so a writer writes only when they differ. +func Same(a, b Node) bool { + a, b = a.normal(), b.normal() + a.V, b.V = 0, 0 + return reflect.DeepEqual(a, b) +} + +// Graph is the module graph as a reader holds it: every graph node, by module. +type Graph map[string]Node + +// Providers are the modules that provide provision: each whose Provides names it, and the module of that name (a module +// provides its own name), sorted. +func (g Graph) Providers(provision string) []string { + var out []string + for name, n := range g { + if name == provision || slices.Contains(n.Provides, provision) { + out = append(out, name) + } + } + slices.Sort(out) + return out +} + +// Requirers are the modules that require provision, not optionally, sorted. +func (g Graph) Requirers(provision string) []string { + var out []string + for name, n := range g { + if slices.Contains(n.Requires, provision) { + out = append(out, name) + } + } + slices.Sort(out) + 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. +func (g Graph) Publisher(pkg string) string { + repository, dir, ok := delivery.PackageRepository(pkg) + if !ok { + return "" + } + if dir == "" { + dir = delivery.RootPath + } + for _, name := range slices.Sorted(maps.Keys(g)) { + if n := g[name]; n.Repository == repository && n.Path == dir { + return name + } + } + return "" +} + +// buildEdges are the modules n's build edges reach: its bases, the publisher of each of its packages, and the build +// module of each language. +func (g Graph) buildEdges(n Node) []string { + out := slices.Clone(n.Bases) + for _, p := range n.Packages { + if m := g.Publisher(p); m != "" { + out = append(out, m) + } + } + for _, t := range n.Toolchains { + out = append(out, t.Module) + } + return sortedOnce(out) +} + +// Dependents are the modules whose build edges reach module, directly or through other modules' (a base of a base, a +// module built by a build module that stands on module), sorted and without module itself (ADR 0604 §1): what a +// change of module builds again. +func (g Graph) Dependents(module string) []string { + reverse := map[string][]string{} + for _, name := range slices.Sorted(maps.Keys(g)) { + for _, to := range g.buildEdges(g[name]) { + reverse[to] = append(reverse[to], name) + } + } + seen := map[string]bool{module: true} + queue := []string{module} + var out []string + for len(queue) > 0 { + at := queue[0] + queue = queue[1:] + for _, from := range reverse[at] { + if !seen[from] { + seen[from] = true + out = append(out, from) + queue = append(queue, from) + } + } + } + slices.Sort(out) + return out +} + +// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases and, for each provision it +// awaits, every other module that provides it (ADR 0603 §1). A toolchain or package edge orders nothing (§3). +func (g Graph) Before(name string) []string { + n := g[name] + out := slices.Clone(n.Bases) + for _, a := range n.Awaits { + for _, p := range g.Providers(a) { + if p != name { + out = append(out, p) + } + } + } + return sortedOnce(out) +} + +// Cycle is the first cycle of the graph's ordering edges, as the modules it passes through with the first named again +// at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges are a +// module's bases and, for each provision it awaits, every other module that provides it: modules ordered on each other +// would each wait for the other for good. A toolchain edge is no ordering edge (ADR 0603 §3: nothing waits on a build +// module), and neither is a package edge. The modules are visited in sorted order, so the same graph names the same +// cycle. +func (g Graph) Cycle() []string { + edges := g.Before + const ( + unseen = iota + onPath + done + ) + state := map[string]int{} + var path []string + var visit func(string) []string + visit = func(name string) []string { + state[name] = onPath + path = append(path, name) + for _, to := range edges(name) { + if _, ours := g[to]; !ours { + continue + } + switch state[to] { + case onPath: + i := slices.Index(path, to) + return append(slices.Clone(path[i:]), to) + case unseen: + if c := visit(to); c != nil { + return c + } + } + } + path = path[:len(path)-1] + state[name] = done + return nil + } + for _, name := range slices.Sorted(maps.Keys(g)) { + if state[name] == unseen { + if c := visit(name); c != nil { + return c + } + } + } + return nil +} + +// EdgesOf is the graph node of manifest's own edges: its bases (each module its build stands on), what it provides, +// what it requires and does not require optionally, and of those what a want of it waits for (catalogue.Awaited, as the +// assigner judges it), providers answering the manifests that provide a provision. The fingerprint, place, packages and +// toolchains are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way. +func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []catalogue.Manifest) Node { + var n Node + if manifest.Build != nil { + for _, on := range manifest.Build.On { + if on.Module != "" { + n.Bases = append(n.Bases, on.Module) + } + } + } + for _, o := range manifest.Provides { + n.Provides = append(n.Provides, o.Name) + } + for _, r := range manifest.Requires { + if !manifest.OptionalRequires[r] { + n.Requires = append(n.Requires, r) + } + } + n.Awaits = catalogue.Awaited(manifest, providers) + return n.normal() +} + +// OfManifests is the graph of these manifests' own edges (EdgesOf), by module, each provision's providers being +// the manifests among them that offer it: what the check works out for a change to find a cycle (ADR 0603 §2). +func OfManifests(manifests map[string]catalogue.Manifest) Graph { + providers := func(provision string) []catalogue.Manifest { + var out []catalogue.Manifest + for _, name := range slices.Sorted(maps.Keys(manifests)) { + if slices.Contains(manifests[name].Offers(), provision) { + out = append(out, manifests[name]) + } + } + return out + } + g := Graph{} + for name, m := range manifests { + g[name] = EdgesOf(m, providers) + } + return g +} diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go new file mode 100644 index 00000000..9c34b9db --- /dev/null +++ b/sdk/graph/graph_test.go @@ -0,0 +1,174 @@ +package graph + +import ( + "errors" + "reflect" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// TestGraphEdges: the module graph's cycle, dependents and providers, each from the edges alone (ADR 0603 §2, ADR 0604 §1). +func TestGraphEdges(t *testing.T) { + t.Run("a node's value", nodeValues) + fp := "src2:" + strings.Repeat("b", 64) + node := func(repository, path string, edit func(*Node)) Node { + n := Node{FP: fp, Repository: repository, Path: path} + if edit != nil { + edit(&n) + } + return n + } + gotool := map[string]Toolchain{"go": {Module: "build-go", Version: "go1", Recipe: "1"}} + // A base chain, a package and a build module: app stands on base, imports lib's package, is built by build-go. + g := Graph{ + "build-go": node("novox/mesh-catalog", "modules/build-go", func(n *Node) { n.Toolchains = gotool }), + "lib": node("novox/lib", delivery.RootPath, nil), + "base": node("novox/mesh-catalog", "modules/base", nil), + "app": node("novox/mesh-catalog", "modules/app", func(n *Node) { + 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"} }), + "web": node("novox/mesh-catalog", "modules/web", func(n *Node) { + n.Requires, n.Awaits = []string{"database", "db"}, []string{"database"} + }), + } + 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 + "top": nil, + "db": nil, // a provision edge is no build edge + } { + if got := g.Dependents(module); !reflect.DeepEqual(got, want) { + t.Errorf("dependents of %s = %v, want %v", module, got, want) + } + } + if got := g.Providers("database"); !reflect.DeepEqual(got, []string{"db"}) { + t.Errorf("providers of database = %v", got) + } + if got := g.Providers("db"); !reflect.DeepEqual(got, []string{"db"}) { // a module provides its own name + t.Errorf("providers of db = %v", got) + } + if got := g.Before("web"); !reflect.DeepEqual(got, []string{"db"}) { // web awaits database, which db provides + t.Errorf("web waits for %v", got) + } + if got := g.Before("top"); !reflect.DeepEqual(got, []string{"app"}) { + t.Errorf("top waits for %v", got) + } + if got := g.Requirers("db"); !reflect.DeepEqual(got, []string{"web"}) { + t.Errorf("requirers of db = %v", got) + } + 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") + } + // From manifests alone (the check's graph): a requirement answered by a helper at mesh scope is awaited, an optional + // one is not, and two modules awaiting each other's provision are a cycle. + offer := func(name string) []catalogue.Offer { + return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}} + } + ms := map[string]catalogue.Manifest{ + "db": {Module: "db", Provides: offer("database"), Requires: []string{"site", "logs"}, OptionalRequires: map[string]bool{"logs": true}}, + "web": {Module: "web", Provides: offer("site"), Requires: []string{"database"}, Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "base"}}}}, + } + mg := OfManifests(ms) + if db := mg["db"]; !reflect.DeepEqual(db.Requires, []string{"site"}) || !reflect.DeepEqual(db.Awaits, []string{"site"}) || + !reflect.DeepEqual(mg["web"].Bases, []string{"base"}) || !reflect.DeepEqual(db.Provides, []string{"database"}) { + t.Errorf("the graph of manifests = %+v", mg) + } + if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) { + t.Errorf("the cycle of manifests = %v", got) + } + for name, c := range map[string]struct { + edit func(Graph) + want []string + }{ + "none: a toolchain on itself and a requirement not awaited order nothing": {func(Graph) {}, nil}, + "bases": {func(g Graph) { + b := g["base"] + b.Bases = []string{"top"} + g["base"] = b + }, []string{"app", "base", "top", "app"}}, + "provisions a -> b -> a": {func(g Graph) { + d := g["db"] + d.Requires, d.Awaits = []string{"site"}, []string{"site"} + g["db"] = d + w := g["web"] + w.Provides = []string{"site"} + g["web"] = w + }, []string{"db", "web", "db"}}, + "a requirement not awaited is no edge": {func(g Graph) { + d := g["db"] + d.Requires = []string{"site"} + g["db"] = d + w := g["web"] + w.Provides = []string{"site"} + g["web"] = w + }, nil}, + } { + h := Graph{} + for k, v := range g { + h[k] = v + } + c.edit(h) + if got := h.Cycle(); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: cycle = %v, want %v", name, got, c.want) + } + } +} + +// sampleNode is a graph node with every kind of edge, its lists unsorted. +func sampleNode() Node { + return Node{FP: "src2:" + strings.Repeat("a", 64), Repository: "novox/mesh-catalog", Path: "modules/gitea", + Bases: []string{"z-base", "a-base"}, Packages: []string{"git.novox.be/novox/mesh-sdk/go"}, + Toolchains: map[string]Toolchain{"go": {Module: "build-go", Version: "go1.27.1", Recipe: "3"}}, + Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"}} +} + +// nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is +// refused naming why, and the state is the indexer's graph (ADR 0603 §2). +func nodeValues(t *testing.T) { + if Bucket != "mesh-delivery-indexer_graph" || Read != "mesh-delivery-indexer.graph" || History != 5 || State != "graph" { + t.Error("the graph's state differs from the decided layout") + } + g := sampleNode() + data, err := Encode(g) + if err != nil || !strings.Contains(string(data), `"v":1`) { + t.Fatalf("encode: %s %v", data, err) + } + if got, err := Decode(data); err != nil || !Same(got, g) || !slices.IsSorted(got.Bases) { + t.Errorf("round trip: %+v %v", got, err) + } + if Same(g, Node{None: "x", Repository: "a/b"}) { + t.Error("different graph nodes are the same") + } + for _, in := range []string{`{"none":"x","repository":"a/b"}`, `{"v":2,"none":"x","repository":"a/b"}`} { + if _, err := Decode([]byte(in)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err) + } + } + for name, c := range map[string]struct { + change func(*Node) + word string + }{ + "both": {func(g *Node) { g.None = "why" }, "both"}, + "neither": {func(g *Node) { g.FP = "" }, "neither"}, + "an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"}, + "a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"}, + "a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"}, + "a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"}, + "a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"}, + "awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"}, + "no repository": {func(g *Node) { g.Repository = "" }, "repositor"}, + } { + g := sampleNode() + c.change(&g) + if _, err := Encode(g); err == nil || !strings.Contains(err.Error(), c.word) { + t.Errorf("a node with %s: %v, want it refused naming %q", name, err, c.word) + } + } +}