diff --git a/docs/config.md b/docs/config.md index 7f7644e..2369a98 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 application-default or Go-zero baseline, then includes only relevant base sources in 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 before domain resolution. Its origin retains the decoded path's source/default attribution; its reason identifies static-default retention and canonical decoding. diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index af3dfc1..f7dc47d 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -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 +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 when a mapping entry is absent, even when the replacement is a newly created value. diff --git a/explain.go b/explain.go index 2e6e084..8b82842 100644 --- a/explain.go +++ b/explain.go @@ -134,7 +134,7 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) { } working = document.Clone(r.input) - for _, snapshot := range r.resolution { + for _, snapshot := range coalescedResolutionSnapshots(r.resolution) { relevant := pathsIntersect(snapshot.change.Path, path) var before ValueSnapshot 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) { if root == nil || *root == nil { return @@ -289,7 +320,19 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N } position := documentEntryIndex(current.Mapping, segment.key) if position < 0 { - return + if replacement == nil { + 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 replacement == nil { diff --git a/explain_test.go b/explain_test.go index 57bb168..c3a395e 100644 --- a/explain_test.go +++ b/explain_test.go @@ -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) + } + } + }) + } +}