diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e3d1da9..073d427 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -211,6 +211,14 @@ Run the everyday checker. ## Stage 5 — Integrate historical inspection +**Completed 2026-09-29.** Source replay stays at original paths, followed by +projected structural control/transfer events before typed reconciliation and +application resolution. History and explanations preserve consumed/cancelled +directives, equal-valued child replacements, displaced descendants, nested +destination results, and parent source updates. Shared retained evidence is +counted with a bounded incremental check; focused tests and `./scripts/check.sh` +passed. Public loading remains unchanged. + Extend `history.go`, `load_metadata.go`, `explain.go`, and explanation rendering. Retain original source applications; represent structural expansion separately, between source replay and canonical decoding, followed by recorded application diff --git a/internal/engine/explain.go b/internal/engine/explain.go index 07c6411..b78720a 100644 --- a/internal/engine/explain.go +++ b/internal/engine/explain.go @@ -72,6 +72,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { Origin: historyApplicationOrigin(application, operationPath), }) } + appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets) // Intermediate input replacements do not permanently suppress defaults: // decoding applies them to the final input tree. Show that reconciliation diff --git a/internal/engine/explain_render.go b/internal/engine/explain_render.go index 52f231f..4b73cb6 100644 --- a/internal/engine/explain_render.go +++ b/internal/engine/explain_render.go @@ -42,6 +42,8 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error { rendered.WriteString(" - representation: ") if change.Origin.Operation == ChangeDecoding { rendered.WriteString("input to canonical typed\n") + } else if change.Origin.Operation == ChangeInheritance { + rendered.WriteString("structural inheritance\n") } else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerUnderlay || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI { rendered.WriteString("policy-validated input\n") } else { @@ -91,6 +93,15 @@ func writeExplanationOrigin(output *strings.Builder, label string, origin Origin output.WriteString(strconv.Itoa(location.Column)) output.WriteByte('\n') } + for _, step := range origin.Inheritance { + output.WriteString(" inherited: from=") + output.WriteString(strconv.Quote(step.From.String())) + output.WriteString(" to=") + output.WriteString(strconv.Quote(step.To.String())) + output.WriteString(" reference=") + output.WriteString(strconv.Quote(step.Reference.String())) + output.WriteByte('\n') + } } func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) { diff --git a/internal/engine/history.go b/internal/engine/history.go index f0aa152..73e30d7 100644 --- a/internal/engine/history.go +++ b/internal/engine/history.go @@ -6,6 +6,40 @@ import ( const historyNodeLimit = 4_000_000 +type retainedNodeCounter struct { + seen map[*document.Node]bool + count int + limit int +} + +func newRetainedNodeCounter(limit int) *retainedNodeCounter { + return &retainedNodeCounter{seen: make(map[*document.Node]bool), limit: limit} +} + +func (c *retainedNodeCounter) add(root *document.Node) bool { + if c == nil { + return true + } + stack := []*document.Node{root} + for len(stack) != 0 { + node := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if node == nil || c.seen[node] { + continue + } + c.seen[node] = true + c.count++ + if c.limit >= 0 && c.count > c.limit { + return false + } + for _, entry := range node.Mapping { + stack = append(stack, entry.Value) + } + stack = append(stack, node.Sequence...) + } + return true +} + // History applications borrow immutable policy-validated input trees. Metadata // records answer presence and origin queries; replay reconstructs value changes. // Replay reconciles with canonical decoding before domain resolution so both @@ -230,11 +264,11 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N } } -func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool { - return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions) +func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool { + return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions, inherited...) } -func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool { +func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool { seen := make(map[*document.Node]bool) count := 0 var visit func(*document.Node) bool @@ -272,5 +306,23 @@ func historyNodesWithin(limit int, baseline, input, final *document.Node, applic return false } } + for _, expansion := range inherited { + if expansion == nil { + continue + } + if !visit(expansion.root) { + return false + } + for _, operation := range expansion.operations { + if !visit(operation.parent) || !visit(operation.effective) { + return false + } + } + for _, replacement := range expansion.replacements { + if !visit(replacement.before) || !visit(replacement.after) { + return false + } + } + } return true } diff --git a/internal/engine/inheritance.go b/internal/engine/inheritance.go index 7e554b3..ff0e442 100644 --- a/internal/engine/inheritance.go +++ b/internal/engine/inheritance.go @@ -29,6 +29,17 @@ type inheritanceScope struct { transfers []inheritanceTransfer controls []inheritanceControl replacements []inheritanceReplacement + operations []inheritanceOperation +} + +type inheritanceOperation struct { + control *inheritanceControl + transfer *inheritanceTransfer + controlOrigin Origin + parent *document.Node + effective *document.Node + parentOrigins map[string]Origin + effectiveOrigins map[string]Origin } // A replacement retains the displaced source/derivation record while the @@ -73,7 +84,7 @@ func expandSiblingDefinitionsWithLimits(scope *document.Node, scopePath Path, li return expandSiblingDefinitionsWithOrigins(scope, scopePath, limits, nil) } -func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin) (*inheritanceScope, []Diagnostic) { +func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) { if scope == nil || scope.Kind != document.Mapping { return nil, []Diagnostic{{ Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath, @@ -93,6 +104,10 @@ func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, l resolvedOrigins := make(map[string]map[string]Origin, len(byName)) state := make(map[string]uint8, len(byName)) result := &inheritanceScope{} + counter := newRetainedNodeCounter(historyNodeLimit) + if len(budget) != 0 { + counter = budget[0] + } retained := 1 // scope mapping node for _, entry := range scope.Mapping { if entry.Value.Kind == document.Mapping { @@ -159,7 +174,19 @@ func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, l } payload := withoutInheritanceDirective(entry.Value) + operationStart, replacementStart := len(result.operations), len(result.replacements) childOrigins := directInheritanceOrigins(payload, path, inputOrigins) + if reference, found := inheritanceDirective(entry.Value); found { + control := inheritanceControl{ + path: path.Key(inheritanceKey), + locations: []Location{publicLocation(reference.Value.Location)}, + cancelled: reference.Value.Kind == document.Null, + } + controlOrigins := directInheritanceOrigins(reference.Value, control.path, inputOrigins) + result.operations = append(result.operations, inheritanceOperation{ + control: &control, controlOrigin: controlOrigins[control.path.String()], + }) + } if limits.nodes >= 0 { remaining := limits.nodes - retained - 1 // the sibling key within := false @@ -203,12 +230,26 @@ func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, l resolved[frame.name] = merged resolvedOrigins[frame.name] = mergedOrigins result.transfers = append(result.transfers, transfer) + result.operations = append(result.operations, inheritanceOperation{ + transfer: &transfer, parent: parent, effective: merged, + parentOrigins: resolvedOrigins[frame.parent], effectiveOrigins: mergedOrigins, + }) } else { resolved[frame.name] = document.Clone(payload) resolvedOrigins[frame.name] = childOrigins } count, _ := inheritanceLowerBoundCount(resolved[frame.name], limits.nodes) retained += 1 + count + for _, operation := range result.operations[operationStart:] { + if !counter.add(operation.parent) || !counter.add(operation.effective) { + return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)} + } + } + for _, replacement := range result.replacements[replacementStart:] { + if !counter.add(replacement.before) || !counter.add(replacement.after) { + return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)} + } + } state[frame.name] = 2 stack = stack[:len(stack)-1] } diff --git a/internal/engine/inheritance_explain.go b/internal/engine/inheritance_explain.go new file mode 100644 index 0000000..5fd5467 --- /dev/null +++ b/internal/engine/inheritance_explain.go @@ -0,0 +1,60 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +func appendInheritanceExplanation(explanation *Explanation, working **document.Node, query Path, operations []inheritanceOperation, secrets map[string]bool) { + for _, operation := range operations { + changes := projectedInheritanceChanges(query, operation) + if operation.control != nil { + if !pathsIntersect(operation.control.path, query) { + continue + } + before := valueSnapshot(*working, query, secrets) + replaceHistoryPath(working, operation.control.path, nil) + after := valueSnapshot(*working, query, secrets) + if len(changes) != 0 { + explanation.History = append(explanation.History, ValueChange{ + Before: before, After: after, Origin: cloneOrigin(changes[0].Origin), + Dependencies: clonePaths(changes[0].Dependencies), + }) + } + continue + } + if operation.transfer == nil { + continue + } + before := valueSnapshot(*working, query, secrets) + replaceHistoryPath(working, operation.transfer.to, operation.parent) + afterParent := valueSnapshot(*working, query, secrets) + parent, parentPresent := transferredOperationOrigin(operation, operationPathForQuery(operation.transfer.to, query)) + if pathsIntersect(operation.transfer.to, query) && parentPresent { + parent.Operation = ChangeInheritance + parent.Inheritance = projectInheritanceSteps(parent) + explanation.History = append(explanation.History, ValueChange{ + Before: before, After: afterParent, Origin: parent, + Dependencies: []Path{operation.transfer.from.clone(), operation.transfer.reference.clone()}, + }) + } + replaceHistoryPath(working, operation.transfer.to, operation.effective) + if !pathsIntersect(operation.transfer.to, query) { + continue + } + afterChild := valueSnapshot(*working, query, secrets) + if len(changes) != 0 { + last := changes[len(changes)-1] + if !parentPresent || len(changes) > 1 { + explanation.History = append(explanation.History, ValueChange{ + Before: afterParent, After: afterChild, Origin: cloneOrigin(last.Origin), + Dependencies: clonePaths(last.Dependencies), + }) + } + } + } +} + +func operationPathForQuery(root, query Path) Path { + if pathContains(query, root) { + return root + } + return query +} diff --git a/internal/engine/inheritance_history.go b/internal/engine/inheritance_history.go new file mode 100644 index 0000000..b68b065 --- /dev/null +++ b/internal/engine/inheritance_history.go @@ -0,0 +1,119 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +// Retain source history at original paths. Structural operations are held once +// per relationship and projected onto a requested destination path only when +// History or Explain is queried. +func applyInheritanceMetadata(metadata *Metadata, raw *document.Node, rawOrigins map[string]Origin, overlays []overlayRecord, expansion *inheritanceScope) { + if metadata == nil || expansion == nil { + return + } + sourceView := Metadata{ + fields: make(map[string]FieldInfo), + history: metadata.history, + } + walkDocument(raw, Path{}, func(path Path, node *document.Node) { + origin, found := rawOrigins[path.String()] + if !found { + origin = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}} + } + sourceView.fields[path.String()] = FieldInfo{Explicit: true, Null: node.Kind == document.Null, Origin: cloneOrigin(origin)} + }) + applyOverlayMetadata(&sourceView, overlays) + metadata.history = sourceView.history + metadata.inheritance = append([]inheritanceOperation(nil), expansion.operations...) +} + +func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change { + split := len(stored) + for index, change := range stored { + if change.Origin.Layer == LayerResolution { + split = index + break + } + } + history := cloneChanges(stored[:split]) + for _, operation := range operations { + for _, change := range projectedInheritanceChanges(path, operation) { + if len(history) != 0 { + change.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)} + } + history = append(history, change) + } + } + for _, change := range stored[split:] { + cloned := cloneChange(change) + if len(history) != 0 { + cloned.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)} + } + history = append(history, cloned) + } + return history +} + +func projectedInheritanceChanges(query Path, operation inheritanceOperation) []Change { + if operation.control != nil { + if !pathsIntersect(operation.control.path, query) { + return nil + } + origin := cloneOrigin(operation.controlOrigin) + origin.Operation = ChangeInheritance + origin.Inheritance = projectInheritanceSteps(origin) + return []Change{{Path: operation.control.path.clone(), Origin: origin, + Dependencies: []Path{operation.control.path.clone()}}} + } + if operation.transfer == nil || !pathsIntersect(operation.transfer.to, query) { + return nil + } + path := query + if pathContains(query, operation.transfer.to) { + path = operation.transfer.to + } + var changes []Change + parent, parentFound := transferredOperationOrigin(operation, path) + if parentFound { + parent.Operation = ChangeInheritance + parent.Inheritance = projectInheritanceSteps(parent) + changes = append(changes, Change{ + Path: path.clone(), Origin: parent, + Dependencies: []Path{operation.transfer.from.clone(), operation.transfer.reference.clone()}, + }) + } + effective, effectiveFound := operation.effectiveOrigins[path.String()] + if effectiveFound { + effective = cloneOrigin(effective) + effective.Operation = ChangeInheritance + } + if effectiveFound && (!parentFound || !originContainedBy(effective, parent)) { + effective.Inheritance = projectInheritanceSteps(effective) + changes = append(changes, Change{ + Path: path.clone(), Origin: effective, + Dependencies: []Path{operation.transfer.reference.clone()}, + }) + } else if parentFound && !effectiveFound { + removed := cloneOrigin(operation.effectiveOrigins[operation.transfer.to.String()]) + removed.Operation = ChangeInheritance + removed.Inheritance = projectInheritanceSteps(removed) + changes = append(changes, Change{ + Path: operation.transfer.to.clone(), Origin: removed, + Dependencies: []Path{operation.transfer.reference.clone()}, + }) + } + return changes +} + +func transferredOperationOrigin(operation inheritanceOperation, destination Path) (Origin, bool) { + relative, ok := relativeHistoryPath(operation.transfer.to, destination) + if !ok { + return Origin{}, false + } + source := appendInheritancePath(operation.transfer.from, relative) + origin, found := operation.parentOrigins[source.String()] + if !found { + return Origin{}, false + } + transferred := transferredInheritanceOrigins(map[string]Origin{source.String(): origin}, + operation.transfer.from, operation.transfer.to, *operation.transfer) + return transferred[destination.String()], true +} diff --git a/internal/engine/inheritance_history_test.go b/internal/engine/inheritance_history_test.go new file mode 100644 index 0000000..5727d09 --- /dev/null +++ b/internal/engine/inheritance_history_test.go @@ -0,0 +1,271 @@ +package engine + +import ( + "bytes" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func inheritanceInspectionForTest(t *testing.T, sources ...*document.Node) (*Inspection, *inheritanceScope) { + t.Helper() + composed, diagnostics := composeDocuments(sources...) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + return inheritanceInspectionFromComposition(t, composed) +} + +func inheritanceInspectionFromComposition(t *testing.T, composed *composedDocument) (*Inspection, *inheritanceScope) { + t.Helper() + expanded, diagnostics := expandInheritanceWithOrigins(composed.root, composed.origins, defaultProjectionLimits()) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + baseline := &document.Node{Kind: document.Mapping} + presence := make(map[string]inputPresence) + collectPresence(expanded.root, Path{}, presence) + decoded := decodedConfiguration{ + baseline: baseline, final: expanded.root, presence: presence, origins: expanded.origins, + } + metadata := metadataFromDecoded(decoded) + applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded) + finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root) + return &Inspection{ + metadata: metadata, baseline: baseline, input: expanded.root, + inputOrigins: expanded.origins, final: expanded.root, + applications: composed.applications, inheritance: expanded.operations, + }, expanded +} + +func TestInheritanceHistoryFollowsFinalParentAfterSourceOverlay(t *testing.T) { + base, diagnostics := composeDocuments(parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n")) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + assembly := newAssemblyAccumulator(base) + if diagnostics := assembly.applyOverlay(parseCompositionDocument(t, "source-2", "parent: {value: latest}\n"), nil, nil, LayerOverlay); len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + inspection, _ := inheritanceInspectionFromComposition(t, assembly.current) + path := Keys("child", "value") + explanation, found := inspection.Explain(path) + if !found || explanation.Effective.YAML != "latest\n" || + len(explanation.History) == 0 || explanation.History[len(explanation.History)-1].Origin.Locations[0].SourceID != "source-2" { + t.Fatalf("overlaid parent contribution = %#v, %v", explanation, found) + } + parentHistory := inspection.Metadata().History(Keys("parent", "value")) + if len(parentHistory) < 2 || parentHistory[0].Origin.Layer != LayerBase || + parentHistory[len(parentHistory)-1].Origin.Layer != LayerOverlay { + t.Fatalf("parent source history = %#v", parentHistory) + } +} + +func TestInheritanceHistoryPreservesCancellationAndSupersededDirective(t *testing.T) { + base, diagnostics := composeDocuments(parseCompositionDocument(t, "source-1", "parent: {value: one}\nchild: {extends: missing, value: own}\n")) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + assembly := newAssemblyAccumulator(base) + if diagnostics := assembly.applyOverlay(parseCompositionDocument(t, "source-2", "child: {extends: null}\n"), nil, nil, LayerOverlay); len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + inspection, _ := inheritanceInspectionFromComposition(t, assembly.current) + control := Keys("child", inheritanceKey) + history := inspection.Metadata().History(control) + if len(history) < 3 || history[0].Origin.Locations[0].SourceID != "source-1" || + history[1].Origin.Locations[0].SourceID != "source-2" || + history[len(history)-1].Origin.Operation != ChangeInheritance { + t.Fatalf("superseded and cancelled directive history = %#v", history) + } + explanation, found := inspection.Explain(control) + if !found || explanation.Effective.Present || len(explanation.History) < 3 || + explanation.History[len(explanation.History)-1].After.Present { + t.Fatalf("cancelled directive explanation = %#v, %v", explanation, found) + } + value, found := inspection.Explain(Keys("child", "value")) + if !found || value.Effective.YAML != "own\n" { + t.Fatalf("cancelled relationship changed own value = %#v, %v", value, found) + } +} + +func TestInheritanceHistoryProjectsNestedDestinationValues(t *testing.T) { + inspection, _ := inheritanceInspectionForTest(t, parseCompositionDocument(t, "source-1", `base: + settings: + primary: {value: old} + secondary: {extends: primary} +custom: + extends: base + settings: + primary: {value: new} +`)) + path := Keys("custom", "settings", "secondary", "value") + history := inspection.Metadata().History(path) + structural := 0 + for _, change := range history { + if change.Origin.Operation == ChangeInheritance { + structural++ + } + } + if structural == 0 { + t.Fatalf("nested destination history = %#v", history) + } + explanation, found := inspection.Explain(path) + if !found || explanation.Effective.YAML != "new\n" || + len(explanation.Origin.Inheritance) == 0 || + explanation.Origin.Inheritance[len(explanation.Origin.Inheritance)-1].To.String() != path.String() { + t.Fatalf("nested destination explanation = %#v, %v", explanation, found) + } + for _, change := range explanation.History { + if change.Origin.Operation == ChangeDecoding { + t.Fatalf("nested inheritance was attributed to decoding: %#v", explanation.History) + } + } +} + +func TestInheritanceHistoryShowsParentAndEqualChildContribution(t *testing.T) { + inspection, _ := inheritanceInspectionForTest(t, + parseCompositionDocument(t, "source-1", "parent: {value: same}\n"), + parseCompositionDocument(t, "source-2", "child: {extends: parent, value: same}\n")) + path := Keys("child", "value") + history := inspection.Metadata().History(path) + var structural []Change + for _, change := range history { + if change.Origin.Operation == ChangeInheritance { + structural = append(structural, change) + } + } + if len(structural) != 2 || structural[0].Origin.Locations[0].SourceID != "source-1" || + structural[1].Origin.Locations[0].SourceID != "source-2" || + len(structural[0].Dependencies) != 2 || structural[0].Dependencies[0].String() != "/parent" || + len(structural[0].Origin.Inheritance) != 1 { + t.Fatalf("equal-valued structural history = %#v", history) + } + explanation, found := inspection.Explain(path) + if !found || explanation.Effective.YAML != "same\n" { + t.Fatalf("explanation = %#v, %v", explanation, found) + } + var equalEvents int + for _, change := range explanation.History { + if change.Origin.Operation == ChangeDecoding { + t.Fatalf("inheritance was attributed to decoding: %#v", explanation.History) + } + if change.Origin.Operation == ChangeInheritance && change.Before.YAML == change.After.YAML { + equalEvents++ + } + } + if equalEvents != 2 { + t.Fatalf("equal parent and child operations = %#v", explanation.History) + } + parentHistory := inspection.Metadata().History(Keys("parent", "value")) + for _, change := range parentHistory { + if change.Origin.Operation == ChangeInheritance { + t.Fatalf("source definition acquired a destination operation: %#v", parentHistory) + } + } +} + +func TestInheritanceHistoryRetainsControlsAndDisplacedPaths(t *testing.T) { + inspection, _ := inheritanceInspectionForTest(t, + parseCompositionDocument(t, "source-1", "parent: {branch: {detail: inherited}}\n"), + parseCompositionDocument(t, "source-2", "child: {extends: parent, branch: replaced}\n")) + control := Keys("child", inheritanceKey) + controlHistory := inspection.Metadata().History(control) + if len(controlHistory) < 2 || controlHistory[len(controlHistory)-1].Origin.Operation != ChangeInheritance { + t.Fatalf("control history = %#v", controlHistory) + } + controlExplanation, found := inspection.Explain(control) + if !found || controlExplanation.Effective.Present || + controlExplanation.History[len(controlExplanation.History)-1].After.Present { + t.Fatalf("consumed control = %#v, %v", controlExplanation, found) + } + path := Keys("child", "branch", "detail") + if _, found := inspection.Metadata().Lookup(path); found { + t.Fatal("displaced descendant remained effective") + } + listed := false + for _, historical := range inspection.Metadata().HistoricalPaths() { + if historical.String() == path.String() { + listed = true + } + } + if !listed { + t.Fatal("displaced inherited descendant was not enumerable") + } + history := inspection.Metadata().History(path) + if len(history) < 2 || history[len(history)-1].Origin.Operation != ChangeInheritance { + t.Fatalf("displaced descendant history = %#v", history) + } + explanation, found := inspection.Explain(path) + if !found || explanation.Effective.Present { + t.Fatalf("displaced descendant explanation = %#v, %v", explanation, found) + } + var sawInherited, sawRemoval bool + for _, change := range explanation.History { + if change.Origin.Operation != ChangeInheritance { + continue + } + sawInherited = sawInherited || change.After.Present + sawRemoval = sawRemoval || (change.Before.Present && !change.After.Present) + } + if !sawInherited || !sawRemoval { + t.Fatalf("displaced value sequence = %#v", explanation.History) + } + var rendered bytes.Buffer + if err := inspection.WriteExplanation(&rendered, path); err != nil || + !strings.Contains(rendered.String(), "structural inheritance") { + t.Fatalf("rendered structural explanation = %q, %v", rendered.String(), err) + } +} + +func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) { + inspection, _ := inheritanceInspectionForTest(t, + parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n")) + path := Keys("child", "value") + replacement := &document.Node{Kind: document.String, Text: "derived"} + snapshot := resolutionSnapshot{ + change: Change{Path: path, Origin: Origin{Layer: LayerResolution, Operation: ChangeNormalization}}, + after: replacement, + } + final := document.Clone(inspection.final) + replaceHistoryPath(&final, path, replacement) + inspection.final = final + inspection.resolution = []resolutionSnapshot{snapshot} + inspection.metadata.history[path.String()] = append(inspection.metadata.history[path.String()], snapshot.change) + inspection.metadata.fields[path.String()] = FieldInfo{Explicit: true, Origin: snapshot.change.Origin} + history := inspection.Metadata().History(path) + if len(history) < 2 || history[len(history)-2].Origin.Operation != ChangeInheritance || + history[len(history)-1].Origin.Operation != ChangeNormalization { + t.Fatalf("resolution order = %#v", history) + } + explanation, found := inspection.Explain(path) + if !found || explanation.Effective.YAML != "derived\n" || + explanation.History[len(explanation.History)-1].Origin.Operation != ChangeNormalization { + t.Fatalf("resolved explanation = %#v, %v", explanation, found) + } +} + +func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) { + shared := &document.Node{Kind: document.String, Text: "shared"} + extra := &document.Node{Kind: document.String, Text: "extra"} + expansion := &inheritanceScope{ + root: shared, + operations: []inheritanceOperation{{parent: shared, effective: shared}}, + replacements: []inheritanceReplacement{{before: shared, after: extra}}, + } + if !historyNodesWithin(2, nil, nil, nil, nil, nil, expansion) || + historyNodesWithin(1, nil, nil, nil, nil, nil, expansion) { + t.Fatal("shared evidence node accounting is incorrect") + } +} + +func TestInheritanceExpansionEnforcesRetainedHistoryBudget(t *testing.T) { + input := parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent, value: replacement}\n") + if _, diagnostics := expandInheritanceWithBudgets(input, nil, defaultProjectionLimits(), 2); len(diagnostics) != 1 || diagnostics[0].Code != CodeExplainLimitExceeded { + t.Fatalf("retained history limit = %#v", diagnostics) + } + if _, diagnostics := expandInheritanceWithBudgets(input, nil, defaultProjectionLimits(), 100); len(diagnostics) != 0 { + t.Fatalf("sufficient retained history budget = %#v", diagnostics) + } +} diff --git a/internal/engine/inheritance_walk.go b/internal/engine/inheritance_walk.go index 5ed66a8..10709ce 100644 --- a/internal/engine/inheritance_walk.go +++ b/internal/engine/inheritance_walk.go @@ -11,9 +11,11 @@ type inheritanceWalker struct { root **document.Node origins map[string]Origin limits projectionLimits + retained *retainedNodeCounter transfers []inheritanceTransfer controls []inheritanceControl replacements []inheritanceReplacement + operations []inheritanceOperation } // expandInheritanceTree resolves each destination scope after its enclosing @@ -24,12 +26,19 @@ func expandInheritanceTree(input *document.Node, limits projectionLimits) (*inhe } func expandInheritanceWithOrigins(input *document.Node, inputOrigins map[string]Origin, limits projectionLimits) (*inheritanceScope, []Diagnostic) { + return expandInheritanceWithBudgets(input, inputOrigins, limits, historyNodeLimit) +} + +func expandInheritanceWithBudgets(input *document.Node, inputOrigins map[string]Origin, limits projectionLimits, historyLimit int) (*inheritanceScope, []Diagnostic) { if input == nil || input.Kind != document.Mapping { return nil, []Diagnostic{{Code: CodeInheritanceInvalid, Severity: SeverityError, Message: "inheritance requires a mapping document root"}} } root := input - walker := inheritanceWalker{root: &root, limits: limits, origins: cloneOriginIndex(inputOrigins)} + walker := inheritanceWalker{ + root: &root, limits: limits, origins: cloneOriginIndex(inputOrigins), + retained: newRetainedNodeCounter(historyLimit), + } if problem := walker.visit(&root, Path{}, true); problem != nil { return nil, []Diagnostic{*problem} } @@ -38,7 +47,7 @@ func expandInheritanceWithOrigins(input *document.Node, inputOrigins map[string] } return &inheritanceScope{ root: root, origins: walker.origins, transfers: walker.transfers, - controls: walker.controls, replacements: walker.replacements, + controls: walker.controls, replacements: walker.replacements, operations: walker.operations, }, nil } @@ -59,11 +68,14 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool w.controls = append(w.controls, inheritanceControl{ path: referencePath, locations: []Location{location}, cancelled: true, }) + origin := directInheritanceOrigins(reference.Value, referencePath, w.origins)[referencePath.String()] + control := inheritanceControl{path: referencePath, locations: []Location{location}, cancelled: true} + w.operations = append(w.operations, inheritanceOperation{control: &control, controlOrigin: origin}) deleteOriginSubtree(w.origins, referencePath, reference.Value) *node = withoutInheritanceDirective(*node) } } - resolved, diagnostics := expandSiblingDefinitionsWithOrigins(*node, path, w.limits, w.origins) + resolved, diagnostics := expandSiblingDefinitionsWithOrigins(*node, path, w.limits, w.origins, w.retained) if len(diagnostics) != 0 { return &diagnostics[0] } @@ -75,6 +87,7 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool w.transfers = append(w.transfers, resolved.transfers...) w.controls = append(w.controls, resolved.controls...) w.replacements = append(w.replacements, resolved.replacements...) + w.operations = append(w.operations, resolved.operations...) if !inheritanceLowerBoundWithin(*w.root, w.limits.nodes) { problem := inheritanceBudgetDiagnostic(path, (*node).Location) return &problem @@ -251,3 +264,11 @@ func inheritanceBudgetDiagnostic(path Path, location document.Location) Diagnost Message: "expanded configuration exceeds the projection limit", Primary: &primary, } } + +func inheritanceHistoryBudgetDiagnostic(path Path, location document.Location) Diagnostic { + primary := publicLocation(location) + return Diagnostic{ + Code: CodeExplainLimitExceeded, Severity: SeverityError, Path: path, + Message: "retained inheritance history exceeds the node limit", Primary: &primary, + } +} diff --git a/internal/engine/load.go b/internal/engine/load.go index c5f91b8..0869b9c 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -86,6 +86,7 @@ type Inspection struct { final *document.Node resolution []resolutionSnapshot applications []historyApplication + inheritance []inheritanceOperation secrets map[string]bool secretReferences map[string]string } diff --git a/internal/engine/load_metadata.go b/internal/engine/load_metadata.go index 93227ed..62c5f74 100644 --- a/internal/engine/load_metadata.go +++ b/internal/engine/load_metadata.go @@ -254,8 +254,9 @@ func cloneMetadata(metadata Metadata) Metadata { return Metadata{} } cloned := Metadata{ - fields: make(map[string]FieldInfo, len(metadata.fields)), - history: make(map[string][]Change, len(metadata.history)), + fields: make(map[string]FieldInfo, len(metadata.fields)), + history: make(map[string][]Change, len(metadata.history)), + inheritance: append([]inheritanceOperation(nil), metadata.inheritance...), } cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths)) for pointer, path := range metadata.effectivePaths { diff --git a/internal/engine/metadata.go b/internal/engine/metadata.go index 06b3625..0c38b4c 100644 --- a/internal/engine/metadata.go +++ b/internal/engine/metadata.go @@ -13,6 +13,7 @@ type Metadata struct { historicalPaths map[string][]Path fields map[string]FieldInfo history map[string][]Change + inheritance []inheritanceOperation } // Lookup returns information for an effective path. Unknown and invalid paths @@ -25,7 +26,9 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) { if !ok { return FieldInfo{}, false } - return cloneFieldInfo(info), true + detached := cloneFieldInfo(info) + detached.Origin.Inheritance = projectInheritanceSteps(detached.Origin) + return detached, true } // IsExplicit reports whether a surviving input contribution explicitly defined @@ -43,15 +46,8 @@ func (m Metadata) History(path Path) []Change { if !m.hasHistoricalPath(path) { return nil } - history, ok := m.history[path.String()] - if !ok { - return nil - } - cloned := make([]Change, len(history)) - for i, change := range history { - cloned[i] = cloneChange(change) - } - return cloned + history := m.history[path.String()] + return historyWithInheritance(path, history, m.inheritance) } // HistoricalPaths returns the paths observed during loading, including effective @@ -136,6 +132,16 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his } walkDocument(application.value, prefix, add) } + for _, operation := range m.inheritance { + if operation.control != nil { + add(operation.control.path, nil) + continue + } + if operation.transfer != nil { + walkDocument(operation.parent, operation.transfer.to, add) + walkDocument(operation.effective, operation.transfer.to, add) + } + } } // effectivePathIndex records document-derived paths with their contextual segment diff --git a/internal/engine/records.go b/internal/engine/records.go index 0f19257..12b89d5 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -31,7 +31,10 @@ type ChangeKind string const ( // ChangeDecoding records the built-in transition to canonical typed values. // It is inspection metadata, not an operation accepted by Resolution.Record. - ChangeDecoding ChangeKind = "decoding" + ChangeDecoding ChangeKind = "decoding" + // ChangeInheritance records structural input expansion. It is inspection + // metadata, not an operation accepted by Resolution.Record. + ChangeInheritance ChangeKind = "inheritance" ChangeDerivedDefault ChangeKind = "derived_default" ChangeNormalization ChangeKind = "normalization" )