From 9d4d2e2a1774163d1c2384ede98acb8ad735fca8 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 23 Oct 2026 20:23:09 +0000 Subject: [PATCH] Build identified record inheritance graphs --- docs/roadmap/implementation.md | 7 +- internal/engine/inheritance_identified.go | 160 ++++++++++++++ .../engine/inheritance_identified_test.go | 200 ++++++++++++++++++ 3 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 internal/engine/inheritance_identified.go create mode 100644 internal/engine/inheritance_identified_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a49edb4..cbb92dd 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–18 complete. +Status: implementation in progress; Stages 1–19 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -674,6 +674,11 @@ scopes; test these helpers before activating payload expansion. Validation: focused inheritance graph/reference tests and the everyday check. +**Completion:** Added private identified-sequence graph validation and stable +dependency scheduling for complete scalar identity references, without changing +the active mapping-sibling expansion path. Verified focused graph/reference tests +and `./scripts/check.sh`. + ## Stage 20 — Prepare Reusable Inheritance Scope Execution Scope: remove mapping-container assumptions from the private template executor diff --git a/internal/engine/inheritance_identified.go b/internal/engine/inheritance_identified.go new file mode 100644 index 0000000..8c3f1b7 --- /dev/null +++ b/internal/engine/inheritance_identified.go @@ -0,0 +1,160 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +type identifiedInheritanceTemplate struct { + item *document.Node + path Path + tuple string + parent int + hasParent bool + cancelled bool + directive *document.Node + directivePath Path +} + +type identifiedInheritanceGraph struct { + scope *document.Node + scopePath Path + fields []string + nodes []identifiedInheritanceTemplate + edges []dependencyEdge + byIdentity map[string]int +} + +// analyzeIdentifiedInheritanceGraph validates relationships inside one +// identified sequence. It prepares a graph only; mapping-sibling expansion +// remains the active inheritance behavior until the reusable scope executor is +// activated. +func analyzeIdentifiedInheritanceGraph(sequence *document.Node, scopePath Path, identityFields []string) (*identifiedInheritanceGraph, []Diagnostic) { + if sequence == nil || sequence.Kind != document.Sequence { + var primary *Location + if sequence != nil { + location := publicLocation(sequence.Location) + primary = &location + } + return nil, []Diagnostic{{ + Code: CodeSequenceInvalid, Severity: SeverityError, Path: scopePath.clone(), + Message: "identified inheritance scope must be a sequence", Primary: primary, + }} + } + fields, err := normalizeSequenceIdentityFields(identityFields) + if err != nil { + location := publicLocation(sequence.Location) + return nil, []Diagnostic{{ + Code: CodeSequenceInvalid, Severity: SeverityError, Path: scopePath.clone(), + Message: "identified inheritance scope requires identity fields", Primary: &location, + }} + } + lookup, diagnostics := validateNormalizedSequenceIdentityFrom( + sequence, scopePath, fields, sequenceCombinationIdentityCodes, 0, + ) + if len(diagnostics) != 0 { + return nil, diagnostics + } + graph := &identifiedInheritanceGraph{ + scope: sequence, scopePath: scopePath.clone(), fields: cloneStrings(fields), + nodes: make([]identifiedInheritanceTemplate, len(sequence.Sequence)), + byIdentity: make(map[string]int, len(lookup)), + } + for tuple, record := range lookup { + graph.byIdentity[tuple] = record.index + graph.nodes[record.index] = identifiedInheritanceTemplate{ + item: sequence.Sequence[record.index], path: scopePath.Index(record.index), tuple: tuple, + } + } + + for index := range graph.nodes { + node := &graph.nodes[index] + directive, found := inheritanceDirective(node.item) + if !found { + continue + } + node.directive = directive.Value + node.directivePath = node.path.Key(inheritanceKey) + referenceLocation := publicLocation(directive.Value.Location) + switch directive.Value.Kind { + case document.Null: + node.cancelled = true + case document.Mapping: + reference, issues := identifiedInheritanceReference(directive.Value, node.directivePath, fields) + if len(issues) != 0 { + return nil, issues + } + parent, exists := graph.byIdentity[reference] + if !exists { + return nil, []Diagnostic{inheritanceDiagnostic( + CodeInheritanceMissing, node.directivePath, referenceLocation, nil, + "referenced record does not exist in this collection", + )} + } + node.parent = parent + node.hasParent = true + graph.edges = append(graph.edges, dependencyEdge{prerequisite: parent, dependent: index}) + default: + return nil, []Diagnostic{inheritanceDiagnostic( + CodeInheritanceInvalid, node.directivePath, referenceLocation, nil, + "identified record inheritance reference must be a mapping or null", + )} + } + } + return graph, nil +} + +func identifiedInheritanceReference(reference *document.Node, path Path, identityFields []string) (string, []Diagnostic) { + allowed := make(map[string]struct{}, len(identityFields)) + for _, field := range identityFields { + allowed[field] = struct{}{} + } + seen := make(map[string]struct{}, len(reference.Mapping)) + for _, entry := range reference.Mapping { + if _, ok := allowed[entry.Key]; !ok { + return "", []Diagnostic{inheritanceDiagnostic( + CodeInheritanceInvalid, path.Key(entry.Key), publicLocation(entry.Value.Location), nil, + "identified record inheritance reference has an unsupported field", + )} + } + seen[entry.Key] = struct{}{} + } + for _, field := range identityFields { + if _, ok := seen[field]; !ok { + return "", []Diagnostic{inheritanceDiagnostic( + CodeInheritanceInvalid, path.Key(field), publicLocation(reference.Location), nil, + "identified record inheritance reference is missing an identity field", + )} + } + } + index := newSequenceMappingIndex(reference) + tuple, _, missing, invalid := index.identity(identityFields) + if missing != "" || invalid >= 0 || invalid == -2 { + value := reference + fieldPath := path.Key(missing) + if invalid >= 0 && invalid < len(reference.Mapping) { + value = reference.Mapping[invalid].Value + fieldPath = path.Key(reference.Mapping[invalid].Key) + } + location := publicLocation(value.Location) + return "", []Diagnostic{inheritanceDiagnostic( + CodeInheritanceInvalid, fieldPath, location, nil, + "identified record inheritance identity fields must be scalar values", + )} + } + return tuple, nil +} + +func (graph *identifiedInheritanceGraph) schedule() ([]int, []Diagnostic) { + keys := make([]dependencyKey, len(graph.nodes)) + for index, node := range graph.nodes { + keys[index] = dependencyKey{"record", graph.scopePath.String(), node.path.String()} + } + order, cycle := scheduleDependencies(keys, graph.edges) + if len(cycle) == 0 { + return order, nil + } + parent, child := graph.nodes[cycle[0]], graph.nodes[cycle[1]] + return nil, []Diagnostic{inheritanceDiagnostic( + CodeInheritanceCycle, child.directivePath, publicLocation(child.directive.Location), + []Location{publicLocation(parent.item.Location)}, + "identified record inheritance contains a cycle", + )} +} diff --git a/internal/engine/inheritance_identified_test.go b/internal/engine/inheritance_identified_test.go new file mode 100644 index 0000000..ee2138a --- /dev/null +++ b/internal/engine/inheritance_identified_test.go @@ -0,0 +1,200 @@ +package engine + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func parseIdentifiedInheritanceSequence(t *testing.T, sourceID, input string) *document.Node { + t.Helper() + var indented strings.Builder + indented.WriteString("jobs:\n") + for _, line := range strings.Split(strings.TrimSuffix(input, "\n"), "\n") { + indented.WriteString(" ") + indented.WriteString(line) + indented.WriteByte('\n') + } + root := parseItemPolicyDocument(t, sourceID, indented.String()) + sequence, found := documentAtPath(root, Keys("jobs")) + if !found || sequence.Kind != document.Sequence { + t.Fatalf("identified sequence missing from test document: %#v", root) + } + return sequence +} + +func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing.T) { + sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2 + tenant: acme + extends: {id: 1.0, tenant: acme} +- id: 1e0 + tenant: acme + extends: {id: 0, tenant: acme} +- id: 0 + tenant: acme +- id: 3 + tenant: acme + extends: null +`) + path := Keys("jobs") + graph, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, path, []string{"tenant", "id"}) + if len(diagnostics) != 0 { + t.Fatalf("analyze identified graph: %#v", diagnostics) + } + if len(graph.edges) != 2 { + t.Fatalf("edges = %#v, want the two links in the inheritance chain", graph.edges) + } + order, diagnostics := graph.schedule() + if len(diagnostics) != 0 { + t.Fatalf("schedule identified graph: %#v", diagnostics) + } + if len(order) != 4 || order[0] != 2 || order[1] != 1 || order[2] != 0 { + t.Fatalf("schedule = %v, want the forward parent chain before its children", order) + } + if graph.nodes[0].path.String() != "/jobs/0" || graph.nodes[0].item != sequence.Sequence[0] || + !graph.nodes[3].cancelled || len(graph.nodes[3].directivePath.segments) == 0 { + t.Fatalf("graph nodes changed authored positions or cancellation: %#v", graph.nodes) + } + if _, found := graph.byIdentity[graph.nodes[1].tuple]; !found { + t.Fatal("identity index does not contain the parent record") + } +} + +func TestIdentifiedInheritanceRequiresChildIdentityBeforeParentLookup(t *testing.T) { + sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child + extends: {id: parent, tenant: acme} +- id: parent + tenant: acme +`) + _, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, Keys("jobs"), []string{"id", "tenant"}) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/jobs/0/tenant" { + t.Fatalf("missing child identity diagnostics = %#v", diagnostics) + } +} + +func TestIdentifiedInheritanceRejectsMalformedAndMissingReferences(t *testing.T) { + tests := []struct { + name string + value string + wantCode Code + }{ + {name: "string shorthand", value: "extends: parent\n", wantCode: CodeInheritanceInvalid}, + {name: "sequence shorthand", value: "extends: [{id: parent, tenant: acme}]\n", wantCode: CodeInheritanceInvalid}, + {name: "missing component", value: "extends: {id: parent}\n", wantCode: CodeInheritanceInvalid}, + {name: "extra component", value: "extends: {id: parent, tenant: acme, extra: value}\n", wantCode: CodeInheritanceInvalid}, + {name: "mapping component", value: "extends: {id: {nested: value}, tenant: acme}\n", wantCode: CodeInheritanceInvalid}, + {name: "missing parent", value: "extends: {id: missing, tenant: acme}\n", wantCode: CodeInheritanceMissing}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sequence := parseIdentifiedInheritanceSequence(t, "bad-reference", "- id: child\n tenant: acme\n "+test.value) + _, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, Keys("jobs"), []string{"id", "tenant"}) + if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != "/jobs/0/extends" && diagnostics[0].Code != CodeInheritanceInvalid { + t.Fatalf("diagnostics = %#v", diagnostics) + } + if diagnostics[0].Code == CodeInheritanceMissing && diagnostics[0].Path.String() != "/jobs/0/extends" { + t.Fatalf("missing-parent path = %s", diagnostics[0].Path) + } + }) + } +} + +func TestIdentifiedInheritanceGraphFindsSelfAndLongerCycles(t *testing.T) { + for _, input := range []string{ + `- id: self + tenant: acme + extends: {id: self, tenant: acme} +`, + `- id: alpha + tenant: acme + extends: {id: beta, tenant: acme} +- id: beta + tenant: acme + extends: {id: gamma, tenant: acme} +- id: gamma + tenant: acme + extends: {id: alpha, tenant: acme} +`, + } { + graph, diagnostics := analyzeIdentifiedInheritanceGraph(parseIdentifiedInheritanceSequence(t, "cycle", input), Keys("jobs"), []string{"id", "tenant"}) + if len(diagnostics) != 0 { + t.Fatalf("analyze cycle graph: %#v", diagnostics) + } + _, diagnostics = graph.schedule() + if len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceCycle || diagnostics[0].Path.String() == "" { + t.Fatalf("cycle diagnostics = %#v", diagnostics) + } + if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "cycle" { + t.Fatalf("cycle primary location = %#v", diagnostics[0].Primary) + } + } +} + +func TestIdentifiedInheritanceScopesCannotReferenceOtherCollections(t *testing.T) { + other := parseIdentifiedInheritanceSequence(t, "other-collection", `- id: parent + tenant: acme +`) + _, diagnostics := analyzeIdentifiedInheritanceGraph(other, Keys("other"), []string{"id", "tenant"}) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + first := parseIdentifiedInheritanceSequence(t, "first-collection", `- id: child + tenant: acme + extends: {id: parent, tenant: acme} +`) + _, diagnostics = analyzeIdentifiedInheritanceGraph(first, Keys("first"), []string{"id", "tenant"}) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceMissing { + t.Fatalf("cross-collection reference diagnostics = %#v", diagnostics) + } +} + +func TestIdentifiedInheritanceAcceptsStrictlyComposedParentReference(t *testing.T) { + left := parseCompositionDocument(t, "reference-left", "extends: {id: parent}\n") + right := parseCompositionDocument(t, "reference-right", "extends: {tenant: acme}\n") + reference, diagnostics := composeDocuments(left, right) + if len(diagnostics) != 0 { + t.Fatalf("compose disjoint reference helpers: %#v", diagnostics) + } + child, diagnostics := composeDocuments(parseCompositionDocument(t, "child-fields", "id: child\ntenant: acme\n"), reference.root) + if len(diagnostics) != 0 { + t.Fatalf("compose child fields and reference: %#v", diagnostics) + } + parent := parseCompositionDocument(t, "parent", "id: parent\ntenant: acme\n") + sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{child.root, parent}} + graph, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, Keys("jobs"), []string{"id", "tenant"}) + if len(diagnostics) != 0 { + t.Fatalf("analyze composed reference: %#v", diagnostics) + } + if len(graph.edges) != 1 || graph.edges[0] != (dependencyEdge{prerequisite: 1, dependent: 0}) { + t.Fatalf("composed reference edge = %#v", graph.edges) + } + + _, diagnostics = composeDocuments( + parseCompositionDocument(t, "overlap-a", "extends: {id: first}\n"), + parseCompositionDocument(t, "overlap-b", "extends: {id: second}\n"), + ) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict { + t.Fatalf("overlapping reference fields should conflict during strict composition: %#v", diagnostics) + } +} + +func TestIdentifiedInheritanceValidatesOnlySurvivingRelationships(t *testing.T) { + lower := parseIdentifiedInheritanceSequence(t, "superseded", `- id: shared + extends: {id: absent, tenant: acme} +`) + higher := parseIdentifiedInheritanceSequence(t, "cancelled", `- id: shared + extends: null +`) + resolution := collectionPolicyResolution{ + operation: sequenceModeUpsert, effectiveIdentityFields: []string{"id"}, + } + combined, diagnostics := combineSequence(lower, higher, resolution, sequenceCombinationContext{destination: Keys("jobs")}) + if len(diagnostics) != 0 { + t.Fatalf("combine surviving reference: %#v", diagnostics) + } + graph, diagnostics := analyzeIdentifiedInheritanceGraph(combined.value, Keys("jobs"), []string{"id"}) + if len(diagnostics) != 0 || len(graph.edges) != 0 || !graph.nodes[0].cancelled { + t.Fatalf("superseded missing reference was validated: graph=%#v diagnostics=%#v", graph, diagnostics) + } +}