Preserve sibling inheritance in configuration history and explanations

This commit is contained in:
2026-09-29 22:52:51 +00:00
parent 462e57df3e
commit f916e7d1f9
13 changed files with 615 additions and 20 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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]
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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,
}
}

View File

@@ -86,6 +86,7 @@ type Inspection struct {
final *document.Node
resolution []resolutionSnapshot
applications []historyApplication
inheritance []inheritanceOperation
secrets map[string]bool
secretReferences map[string]string
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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"
)