diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_edges.go b/modules/mesh-delivery-indexer/internal/index/catalog_edges.go index 0f6aaad5..a26a47c4 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_edges.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_edges.go @@ -21,6 +21,7 @@ import ( // WithEdges gives each entry of a revision its edges. The entries given are not changed. The edges of kinds 1 to 4 and 6 are // each entry's own; those of kind 5 (§711) need the others' edges of kinds 1 to 4, so they come after. func WithEdges(entries map[string]meshcatalog.CatalogEntry) map[string]meshcatalog.CatalogEntry { + entries = withCallsLeftOut(entries) out := make(map[string]meshcatalog.CatalogEntry, len(entries)) for module, e := range entries { e.Edges = edgesOf(e, entries) @@ -42,6 +43,62 @@ func WithEdges(entries map[string]meshcatalog.CatalogEntry) map[string]meshcatal return out } +// withCallsLeftOut gives each entry the seat call words left out on a cycle (design 47 G A-361, A-357; ADR 0614 §3): the +// module graph's LeaveOut is run over the entries with a package's seat call words (seatCallsOf) counted among the invokes of +// every module built against it, and what it leaves out joins each entry's LeftOut, so a call word's edge on a cycle is +// left out like an invoked verb's, and so is an invoked verb's edge that a call word's edge closes a cycle with. The entries +// given are not changed; with no seat call word anywhere they are returned as they are. +func withCallsLeftOut(entries map[string]meshcatalog.CatalogEntry) map[string]meshcatalog.CatalogEntry { + g, any := graph.Graph{}, false + for module, e := range entries { + calls := seatCallsOf(e, entries) + any = any || len(calls) > 0 + invokes := slices.Clone(e.Invokes) + for _, c := range calls { + if !slices.Contains(invokes, c) { + invokes = append(invokes, c) + } + } + g[module] = graph.Node{Bases: e.Bases, Awaits: e.Awaits, Provides: e.Provides, Invokes: invokes, Serves: e.Serves} + } + if !any { + return entries + } + g.LeaveOut() + out := make(map[string]meshcatalog.CatalogEntry, len(entries)) + for module, e := range entries { + for _, v := range g[module].LeftOut { + if !slices.Contains(e.LeftOut, v) { + e.LeftOut = append(slices.Clone(e.LeftOut), v) + } + } + out[module] = e + } + return out +} + +// seatCallsOf are the call words in the invokes form, `seat:.`, of the package artifacts entry a builds +// against (their `calls`), sorted, each once. +func seatCallsOf(a meshcatalog.CatalogEntry, all map[string]meshcatalog.CatalogEntry) []string { + var out []string + for _, name := range slices.Sorted(maps.Keys(a.Packages)) { + for _, m := range slices.Sorted(maps.Keys(all)) { + for _, art := range all[m].Artifacts { + if art.Package == nil || art.Package.Name != name { + continue + } + for _, word := range wordsOf(all[m], art.Ref, "calls") { + if _, _, isSeat := catalogue.SeatInvoke(word); isSeat && !slices.Contains(out, word) { + out = append(out, word) + } + } + } + } + } + slices.Sort(out) + return out +} + // kind5Steps are the steps of an acting rule that give an edge of kind 5 (§711): a grant or a credential, a composition, a // placement and a verdict, each made for the dependent's new version. `build` gives kind 3, `take-in` and `validate` none. var kind5Steps = []string{seatdef.StepGrant, seatdef.StepCompose, seatdef.StepPlace, seatdef.StepCheck, seatdef.StepPipeline} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go index cf10ec52..07f9d977 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go @@ -122,6 +122,47 @@ func TestAModuleBuiltOnAPackageThatCallsTheToolRunnerIsOrderedAfterTheArtifactTh } } +// Design 47 G A-361: a package's seat call words count among the invokes of every module built against it when the graph +// leaves out the edges on a cycle, so a call word's edge on a cycle is left out like an invoked verb's (the gate and the +// indexer: the gate invokes the indexer's verb, the indexer's SDK calls the gate's), and an edge off the cycle stays. +func TestASeatCallWordsEdgeOnACycleIsLeftOutLikeAnInvokedVerbs(t *testing.T) { + sdk := map[string]any{"module": "sdk", "build": map[string]any{"artifacts": []any{map[string]any{"name": "go", + "calls": []any{"seat:mesh-delivery.source", "seat:mesh-catalog.registrations"}}}}} + pkgs := map[string]string{"git.example/sdk/go": "1.0.4"} + entries := map[string]meshcatalog.CatalogEntry{ + "sdk": {Module: "sdk", Artifacts: []meshcatalog.Artifact{{Ref: "sdk:go", Kind: "package", Language: "go", Package: &meshcatalog.ArtifactPackage{Ecosystem: EcosystemGo, Name: "git.example/sdk/go"}}}, + Manifest: sdk}, + "gate": {Module: "gate", Artifacts: []meshcatalog.Artifact{{Ref: "gate:program", Language: "go"}}, Serves: []string{"seat:mesh-delivery.source"}, + Invokes: []string{"seat:mesh-catalog.registrations"}}, + "indexer": {Module: "indexer", Artifacts: []meshcatalog.Artifact{{Ref: "indexer:program", Language: "go"}}, Serves: []string{"seat:mesh-catalog.registrations"}, + Packages: pkgs}, + "app": {Module: "app", Artifacts: []meshcatalog.Artifact{{Ref: "app:program", Language: "go"}}, Packages: pkgs}, + } + got := WithEdges(entries) + for _, m := range []string{"gate", "indexer"} { + for _, e := range got[m].Edges { + if e.Kind == 6 { + t.Errorf("%s keeps the edge %+v, which lies on the cycle gate-indexer", m, e) + } + } + } + if !slices.Contains(got["indexer"].LeftOut, "seat:mesh-delivery.source") || !slices.Contains(got["gate"].LeftOut, "seat:mesh-catalog.registrations") { + t.Errorf("left out: indexer %v, gate %v, want each one's word on the cycle", got["indexer"].LeftOut, got["gate"].LeftOut) + } + var six []meshcatalog.ArtifactRef + for _, e := range got["app"].Edges { + if e.Kind == 6 { + six = append(six, e.From) + } + } + if !slices.Equal(six, []meshcatalog.ArtifactRef{"gate:program", "indexer:program"}) || len(got["app"].LeftOut) != 0 { + t.Errorf("app's edges of kind 6 come from %v, left out %v, want both servers and none left out", six, got["app"].LeftOut) + } + if len(entries["gate"].LeftOut) != 0 { + t.Error("the entries given were changed") + } +} + func actingEntries() map[string]meshcatalog.CatalogEntry { rule := func(seat, step, on string) meshcatalog.ActingRule { return meshcatalog.ActingRule{Seat: seat, Step: step, On: on}