From c296eba6c1fbf3215e34700258a2489cadf53b64 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 14:54:47 +0000 Subject: [PATCH] Explain matched resolution and removed records --- docs/roadmap/implementation.md | 2 + internal/engine/explain.go | 2 +- internal/engine/resolution_compare_test.go | 143 +++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 95ffc0a..128c169 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -307,6 +307,8 @@ every source form across every case. Collection queries keep original list order ## Stage 9 — Explain net resolution and preserve removed evidence +**Status:** Complete. Focused history and explanation checks passed for moves, removals, additions, and reused positions. + **Scope:** resolution replay groups, structural changes, and reused addresses. 1. For matched-record queries, read a group's before side using the entry path diff --git a/internal/engine/explain.go b/internal/engine/explain.go index 5f90bec..8aab44b 100644 --- a/internal/engine/explain.go +++ b/internal/engine/explain.go @@ -152,7 +152,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { continue } covering := model.resolutionPayload.snapshots[group.coveringRecordID] - before := valueSnapshot(working, path, r.secrets) + before := valueSnapshot(working, entryPath, r.secrets) replaceHistoryPath(&working, group.change.Path, covering.after) after := valueSnapshot(working, path, r.secrets) if !before.Present && !after.Present { diff --git a/internal/engine/resolution_compare_test.go b/internal/engine/resolution_compare_test.go index ba563a4..c5ab486 100644 --- a/internal/engine/resolution_compare_test.go +++ b/internal/engine/resolution_compare_test.go @@ -2,6 +2,7 @@ package engine import ( "errors" + "strings" "testing" ) @@ -390,3 +391,145 @@ func TestMovedRecordExplanationIncludesCLIValue(t *testing.T) { } t.Fatalf("CLI history = %#v", explanation.History) } + +func TestRegisteredResolutionExplainsRemovalAndReusedPositions(t *testing.T) { + type item struct { + ID string `yaml:"id"` + Value string `yaml:"value"` + } + type config struct { + Items []item `yaml:"items"` + } + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error { + value.Items = value.Items[1:] + r.RecordNormalization(Keys("items"), "remove first") + return nil + }}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items:\n - id: reports\n value: report\n - id: invoices\n value: invoice\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + path := Keys("items").Index(0).Key("value") + explanation, ok := result.Inspection.Explain(path) + if !ok { + t.Fatal("survivor explanation missing") + } + found := false + for _, change := range explanation.History { + if change.Origin.Layer == LayerResolution { + if change.Before.YAML != "invoice\n" || change.After.YAML != "invoice\n" { + t.Fatalf("survivor operation = %#v", change) + } + found = true + } + if change.Origin.Layer == LayerMain && change.After.YAML == "report\n" { + t.Fatalf("removed occupant leaked into survivor: %#v", change) + } + } + if !found { + t.Fatal("missing collection operation") + } + container, ok := result.Inspection.Explain(Keys("items")) + if !ok { + t.Fatal("collection explanation missing") + } + found = false + for _, change := range container.History { + if change.Origin.Layer == LayerResolution && strings.Contains(change.Before.YAML, "reports") && !strings.Contains(change.After.YAML, "reports") { + found = true + } + } + if !found { + t.Fatalf("missing removed collection evidence: %#v", container.History) + } + mainEvidence := false + for _, change := range result.Metadata().History(Keys("items")) { + if change.Origin.Layer == LayerMain && len(change.Origin.Locations) != 0 { + mainEvidence = true + } + } + if !mainEvidence { + t.Fatal("collection history lost source attribution") + } + if _, ok := result.Inspection.Explain(Keys("items").Index(1)); !ok { + t.Fatal("absent historical position missing") + } + if _, ok := result.Inspection.Explain(Keys("items").Index(8)); ok { + t.Fatal("unknown position reported") + } +} + +func TestRegisteredResolutionExplainsAddedAndChangedIdentities(t *testing.T) { + type item struct { + ID string `yaml:"id"` + Value string `yaml:"value"` + } + type config struct { + Items []item `yaml:"items"` + } + for _, replace := range []bool{false, true} { + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error { + if replace { + value.Items[0] = item{ID: "new", Value: "added"} + } else { + value.Items = append([]item{{ID: "new", Value: "added"}}, value.Items...) + } + r.RecordNormalization(Keys("items"), "update collection") + return nil + }}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items: [{id: old, value: original}]\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value")) + if !ok { + t.Fatal("added explanation missing") + } + for _, change := range explanation.History { + if change.Origin.Layer == LayerMain || (change.Origin.Layer == LayerResolution && change.Before.Present) { + t.Fatalf("added record inherited previous value: %#v", explanation.History) + } + } + if !replace { + moved, ok := result.Inspection.Explain(Keys("items").Index(1).Key("value")) + if !ok { + t.Fatal("shifted survivor missing") + } + found := false + for _, change := range moved.History { + if change.Origin.Layer == LayerResolution && change.Before.YAML == "original\n" && change.After.YAML == "original\n" { + found = true + } + } + if !found { + t.Fatalf("shifted survivor = %#v", moved.History) + } + } + } +} + +func TestRegisteredResolutionExplainsRemovedFieldOnMovedRecord(t *testing.T) { + type config struct { + Items []map[string]any `yaml:"items"` + } + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error { + delete(value.Items[0], "extra") + value.Items[0], value.Items[1] = value.Items[1], value.Items[0] + r.RecordNormalization(Keys("items"), "remove and reorder") + return nil + }}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: own}, {id: b, extra: other}]\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + explanation, ok := result.Inspection.Explain(Keys("items").Index(1).Key("extra")) + if !ok { + t.Fatal("removed field explanation missing") + } + for _, change := range explanation.History { + if change.Origin.Layer == LayerResolution && change.Before.YAML == "own\n" && !change.After.Present { + return + } + } + t.Fatalf("removed field history = %#v", explanation.History) +}