diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d4b8066..95ffc0a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -280,6 +280,8 @@ replay and removed-position handling using this same selection owner. ## Stage 8 — Replay earlier explanation values at the entry coordinate +**Status:** Complete. Focused source, inheritance, CLI, history, and explanation checks passed. + **Scope:** pre-resolution explanation replay, including inheritance and CLI. 1. Extend the query-local replay selection context in `explain.go` to use the diff --git a/internal/engine/explain.go b/internal/engine/explain.go index 69dfcca..5f90bec 100644 --- a/internal/engine/explain.go +++ b/internal/engine/explain.go @@ -49,6 +49,11 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { return Explanation{}, false } selections := model.indexReplaySelections(selected) + coordinate := model.queryCoordinates(path) + entryPath := coordinate.entry + if !coordinate.hasEntry { + entryPath = Path{invalid: true} + } explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, model.documentLookup)} if info, ok := r.metadata.Lookup(path); ok { explanation.Origin = cloneOrigin(info.Origin) @@ -57,7 +62,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { working := document.Clone(model.baseline) if baseline, found := selections.byPayload[historyPayloadRef{phase: historyPhaseBaseline, id: 0}]; found && baseline.after { explanation.History = append(explanation.History, ValueChange{ - After: valueSnapshot(working, path, r.secrets), + After: valueSnapshot(working, entryPath, r.secrets), Origin: cloneOrigin(baseline.attribution.origin), }) } @@ -67,13 +72,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { continue } application := payload.application - before := valueSnapshot(working, path, r.secrets) + before := valueSnapshot(working, entryPath, r.secrets) applyHistoryApplication(&working, application, replayIndex) selected, relevant := selections.application(historyPayloadRef{phase: historyPhaseSources, id: payloadID}) if !relevant { continue } - after := valueSnapshot(working, path, r.secrets) + after := valueSnapshot(working, entryPath, r.secrets) if !before.Present && !after.Present { continue } @@ -83,20 +88,20 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { Origin: cloneOrigin(selected.attribution.origin), }) } - appendInheritanceExplanation(&explanation, &working, path, model, selections, r.secrets) + appendInheritanceExplanation(&explanation, &working, entryPath, model, selections, r.secrets) // Intermediate input replacements do not permanently suppress defaults: // decoding applies them to the final input tree. Show that reconciliation // explicitly before typed CLI contributions and domain resolution. if checkpoint := model.preCLICheckpoint; checkpoint != nil { selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePreCLI, id: 0}] - replayed, _ := documentAtPath(working, path) - decoded, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup) + replayed, _ := documentAtPath(working, entryPath) + decoded, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup) if selected && !configurationValuesEqual(replayed, decoded) { origin, reasons := checkpointExplanationAttribution(selection) explanation.History = append(explanation.History, ValueChange{ - Before: valueSnapshot(working, path, r.secrets), - After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup), + Before: valueSnapshot(working, entryPath, r.secrets), + After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup), Origin: origin, Reasons: reasons, }) @@ -109,13 +114,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { continue } applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID} - before := valueSnapshot(working, path, r.secrets) + before := valueSnapshot(working, entryPath, r.secrets) applyHistoryApplication(&working, payload.application, replayIndex) selected, relevant := selections.application(applicationRef) if !relevant { continue } - after := valueSnapshot(working, path, r.secrets) + after := valueSnapshot(working, entryPath, r.secrets) if !before.Present && !after.Present { continue } @@ -127,13 +132,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { } if checkpoint := model.postCLICheckpoint; checkpoint != nil { selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePostCLI, id: 0}] - replayed, _ := documentAtPath(working, path) - effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup) + replayed, _ := documentAtPath(working, entryPath) + effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup) if selected && !configurationValuesEqual(replayed, effectiveInput) { origin, reasons := checkpointExplanationAttribution(selection) explanation.History = append(explanation.History, ValueChange{ - Before: valueSnapshot(working, path, r.secrets), - After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup), + Before: valueSnapshot(working, entryPath, r.secrets), + After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup), Origin: origin, Reasons: reasons, }) diff --git a/internal/engine/resolution_compare_test.go b/internal/engine/resolution_compare_test.go index e6c1a95..ba563a4 100644 --- a/internal/engine/resolution_compare_test.go +++ b/internal/engine/resolution_compare_test.go @@ -303,6 +303,23 @@ func TestRegisteredResolutionHistoryFollowsMatchedSource(t *testing.T) { if !found { t.Errorf("missing source line %d in %#v", check.line, history) } + explanation, ok := result.Inspection.Explain(path) + if !ok { + t.Fatalf("missing explanation for %s", path.String()) + } + want := "second\n" + if check.index == 1 { + want = "first\n" + } + foundValue := false + for _, change := range explanation.History { + if change.Origin.Layer == LayerMain && change.After.YAML == want { + foundValue = true + } + } + if !foundValue { + t.Errorf("missing matched input %q in %#v", want, explanation.History) + } } for _, change := range result.Metadata().History(Keys("items").Index(2).Key("value")) { if change.Origin.Layer == LayerMain { @@ -310,3 +327,66 @@ func TestRegisteredResolutionHistoryFollowsMatchedSource(t *testing.T) { } } } + +func TestMovedRecordExplanationIncludesInheritedValue(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[0], value.Items[1] = value.Items[1], value.Items[0] + r.RecordNormalization(Keys("items"), "reorder") + return nil + }}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items: [{id: parent, value: inherited}, {id: child, extends: {id: parent}}]\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("missing explanation") + } + found := false + for _, change := range explanation.History { + if change.After.YAML == "inherited\n" { + found = true + } + } + if !found { + t.Fatalf("inherited history = %#v", explanation.History) + } +} + +func TestMovedRecordExplanationIncludesCLIValue(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[0], value.Items[1] = value.Items[1], value.Items[0] + r.RecordNormalization(Keys("items"), "reorder") + return nil + }}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(NoSource(), Options[config]{Definition: definition, Overrides: []Override{{ + Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "a", "value": "first"}, map[string]any{"id": "b", "value": "second"}}, + }}}) + if err != nil { + t.Fatal(err) + } + explanation, ok := result.Inspection.Explain(Keys("items").Index(1).Key("value")) + if !ok { + t.Fatal("missing explanation") + } + for _, change := range explanation.History { + if change.Origin.Layer == LayerCLI && change.After.YAML == "first\n" { + return + } + } + t.Fatalf("CLI history = %#v", explanation.History) +}