Replay added configuration paths in explanations
This commit is contained in:
@@ -162,7 +162,10 @@ parser values. `Present=false` with empty YAML means absence or deletion; explic
|
|||||||
null is present and renders as `null`. The history starts with the canonical typed
|
null is present and renders as `null`. The history starts with the canonical typed
|
||||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||||
traversal order, the selected profile, supplied CLI records, and audited net
|
traversal order, the selected profile, supplied CLI records, and audited net
|
||||||
resolution operations. Explicit equal writes remain visible. When input replay
|
resolution operations. Overlapping parent/child resolution records are rendered
|
||||||
|
as one net subtree event with combined reasons and dependencies, rather than
|
||||||
|
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||||
|
writes remain visible. When input replay
|
||||||
differs from the final typed input, a `ChangeDecoding` event records the transition
|
differs from the final typed input, a `ChangeDecoding` event records the transition
|
||||||
before domain resolution. Its origin retains the decoded path's source/default
|
before domain resolution. Its origin retains the decoded path's source/default
|
||||||
attribution; its reason identifies static-default retention and canonical decoding.
|
attribution; its reason identifies static-default retention and canonical decoding.
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ the effective value and origin without implying that a final-present path is abs
|
|||||||
|
|
||||||
## 5. P2 — Replay newly created resolution paths
|
## 5. P2 — Replay newly created resolution paths
|
||||||
|
|
||||||
|
Status: complete. Replay inserts new mapping entries/subtrees and coalesces
|
||||||
|
overlapping parent/child net operations while retaining reasons and dependencies.
|
||||||
|
Leaf/container regressions cover nested creation, removal and overlapping records.
|
||||||
|
Full tests pass with the issue-8 TMPDIR workaround; vet, build and whitespace pass.
|
||||||
|
|
||||||
Evidence: [explain.go](../../explain.go), `replaceHistoryPath`, returns immediately
|
Evidence: [explain.go](../../explain.go), `replaceHistoryPath`, returns immediately
|
||||||
when a mapping entry is absent, even when the replacement is a newly created value.
|
when a mapping entry is absent, even when the replacement is a newly created value.
|
||||||
|
|
||||||
|
|||||||
45
explain.go
45
explain.go
@@ -134,7 +134,7 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
working = document.Clone(r.input)
|
working = document.Clone(r.input)
|
||||||
for _, snapshot := range r.resolution {
|
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
|
||||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||||
var before ValueSnapshot
|
var before ValueSnapshot
|
||||||
if relevant {
|
if relevant {
|
||||||
@@ -271,6 +271,37 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolution records describe one before/final transformation, not assignment
|
||||||
|
// order. A parent already carries its descendants' final values; merge overlapping
|
||||||
|
// intent into that event rather than replay a child assignment a second time.
|
||||||
|
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||||
|
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
merged := false
|
||||||
|
for i := range groups {
|
||||||
|
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, reason := range snapshot.change.Reasons {
|
||||||
|
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
|
||||||
|
}
|
||||||
|
for _, dependency := range snapshot.change.Dependencies {
|
||||||
|
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
|
||||||
|
}
|
||||||
|
if snapshot.change.Origin.Operation == ChangeNormalization {
|
||||||
|
groups[i].change.Origin.Operation = ChangeNormalization
|
||||||
|
}
|
||||||
|
merged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !merged {
|
||||||
|
snapshot.change = cloneChange(snapshot.change)
|
||||||
|
groups = append(groups, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
||||||
if root == nil || *root == nil {
|
if root == nil || *root == nil {
|
||||||
return
|
return
|
||||||
@@ -289,8 +320,20 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N
|
|||||||
}
|
}
|
||||||
position := documentEntryIndex(current.Mapping, segment.key)
|
position := documentEntryIndex(current.Mapping, segment.key)
|
||||||
if position < 0 {
|
if position < 0 {
|
||||||
|
if replacement == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
child := &document.Node{Kind: document.Mapping}
|
||||||
|
if last {
|
||||||
|
child = document.Clone(replacement)
|
||||||
|
}
|
||||||
|
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||||
|
if last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = child
|
||||||
|
continue
|
||||||
|
}
|
||||||
if last {
|
if last {
|
||||||
if replacement == nil {
|
if replacement == nil {
|
||||||
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
||||||
|
|||||||
@@ -365,3 +365,48 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
|
||||||
|
type cfg struct {
|
||||||
|
M map[string]any `yaml:"m"`
|
||||||
|
}
|
||||||
|
for _, overlap := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprint(overlap), func(t *testing.T) {
|
||||||
|
result, err := Load(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
|
||||||
|
c.M["new"] = map[string]any{"child": int64(7)}
|
||||||
|
delete(c.M, "old")
|
||||||
|
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old"))
|
||||||
|
r.Record(Keys("m", "old"), ChangeNormalization, "remove old")
|
||||||
|
if overlap {
|
||||||
|
r.Record(Keys("m"), ChangeNormalization, "update container")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("m"), Keys("m", "new"), Keys("m", "new", "child"), Keys("m", "old")} {
|
||||||
|
x, ok := result.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing known history")
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
for _, v := range x.History {
|
||||||
|
if v.Origin.Layer == LayerResolution {
|
||||||
|
count++
|
||||||
|
if overlap && len(v.Reasons) != 3 {
|
||||||
|
t.Fatal("lost overlapping intent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if overlap && count != 1 {
|
||||||
|
t.Fatalf("overlapping records applied %d times", count)
|
||||||
|
}
|
||||||
|
last := x.History[len(x.History)-1]
|
||||||
|
if last.After != x.Effective {
|
||||||
|
t.Fatalf("incorrect replay for %s: %#v", path, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user