Preserve sibling inheritance in configuration history and explanations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
60
internal/engine/inheritance_explain.go
Normal file
60
internal/engine/inheritance_explain.go
Normal 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
|
||||
}
|
||||
119
internal/engine/inheritance_history.go
Normal file
119
internal/engine/inheritance_history.go
Normal 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
|
||||
}
|
||||
271
internal/engine/inheritance_history_test.go
Normal file
271
internal/engine/inheritance_history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ type Inspection struct {
|
||||
final *document.Node
|
||||
resolution []resolutionSnapshot
|
||||
applications []historyApplication
|
||||
inheritance []inheritanceOperation
|
||||
secrets map[string]bool
|
||||
secretReferences map[string]string
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user