From 8a89806e44f93d167ed5c3a3958ef4a3ca9488fa Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 17 Oct 2026 04:45:49 +0000 Subject: [PATCH] Explain descendants removed by sequence replacement --- docs/roadmap/remediation.md | 7 ++++++- facade_sequence_test.go | 31 +++++++++++++++++++++++++++++++ internal/engine/history.go | 10 +++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index f2de426..8509359 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -92,7 +92,12 @@ items retain the same guarantees. ## 3. Record removals caused by registered-sequence replacement — P2 -- [ ] Addressed and regression-tested. +- [x] Addressed and regression-tested. + +Completion: replacement queries include missing descendants and use the replacing +container's origin. Public regressions verify shorter lists, empty lists, and +removed fields; existing append-relevance tests remain green. Focused facade +tests and the everyday check passed. **Owner:** `applicationOperationPath` in [history.go](../../internal/engine/history.go), lines 134–149. diff --git a/facade_sequence_test.go b/facade_sequence_test.go index 526dc3d..71cf1f4 100644 --- a/facade_sequence_test.go +++ b/facade_sequence_test.go @@ -71,6 +71,37 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) { } } +func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + upper []any + path configkit.Path + }{ + {"shorter", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(1).Key("id")}, + {"empty", []any{}, configkit.Keys("records").Index(0).Key("id")}, + {"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{"records": test.upper})}, configkit.LayeredOptions[map[string]any]{ + Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}}, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"records": []any{map[string]any{"id": "one", "text": "old"}, map[string]any{"id": "two"}}})}}}}, + }) + if err != nil { + t.Fatal(err) + } + explanation, ok := result.Explain(test.path) + if !ok || explanation.Effective.Present || len(explanation.History) < 2 { + t.Fatalf("removal not explained: %#v", explanation) + } + last := explanation.History[len(explanation.History)-1] + if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerBase || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID { + t.Fatalf("wrong removal transition/origin: %#v", last) + } + }) + } +} + func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) { definition := configkit.Definition[map[string]any]{ SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}}, diff --git a/internal/engine/history.go b/internal/engine/history.go index 735b468..54db081 100644 --- a/internal/engine/history.go +++ b/internal/engine/history.go @@ -139,7 +139,15 @@ func applicationOperationPath(application historyApplication, query Path, lookup } sequencePathMatched = true if pathContains(container, query) && len(query.segments) > len(container.segments) { - if _, ok := historyApplicationLocalPath(application, query); !ok { + local, ok := historyApplicationLocalPath(application, query) + if contribution.mode == sequenceHistoryReplace { + if _, present := documentAtPathWithLookup(application.value, local, lookup); !ok || !present { + // A replacement also changes descendants missing from the + // incoming list. Attribute their removal to its container. + return container, true + } + } + if !ok { continue } }