diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6c0651f..42ab845 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Interface Contract Remediation Implementation Plan -Status: in progress. Stages 1–10 are complete; Stages 11–18 remain pending. +Status: in progress. Stages 1–11 are complete; Stages 12–18 remain pending. Baseline: `f6d54a094a2b5ada5748686620659399dce7ebf0`. Audience: a Luna coding agent executing the stages sequentially. @@ -555,7 +555,10 @@ root history regressions. ## Stage 11 — Cut Explanation Over To Common Selections Owners: `explain.go`, `inheritance_explain.go`, `snapshot.go`, model replay helpers. -Status: pending. +Status: complete. Validation: +`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Explain|Explanation|History|Render|Diff' -count=1`, +`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -count=1`, +`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, and `git diff --check`. 1. Drive phase chronology and event relevance through model selections. Remove independent relevance/origin interpretation from `Inspection.Explain` and diff --git a/internal/engine/explain.go b/internal/engine/explain.go index cbdf758..d9609c0 100644 --- a/internal/engine/explain.go +++ b/internal/engine/explain.go @@ -37,7 +37,15 @@ type Explanation struct { // Explain returns safe effective and chronological values for a known final or // historical path. Unknown and malformed paths return false. func (r *Inspection) Explain(path Path) (Explanation, bool) { - if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) { + if r == nil || r.final == nil || !path.valid() { + return Explanation{}, false + } + model := r.metadata.historyModel + if model == nil { + return Explanation{}, false + } + selections := model.selectHistory(path) + if len(selections) == 0 && !model.hasObservedShape(path) { return Explanation{}, false } explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, r.immutableLookup)} @@ -45,20 +53,22 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { explanation.Origin = cloneOrigin(info.Origin) } - working := document.Clone(r.baseline) - if _, present := documentAtPath(working, path); present { + working := document.Clone(model.baseline) + if baseline, found := historySelectionForPayload(selections, historyPayloadRef{phase: historyPhaseBaseline, id: 0}); found && baseline.after { explanation.History = append(explanation.History, ValueChange{ - After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault}, + After: valueSnapshot(working, path, r.secrets), + Origin: cloneOrigin(baseline.attribution.origin), }) } replayIndex := new(mappingIndex) - for _, application := range r.applications { - operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup) - var before ValueSnapshot - if relevant { - before = valueSnapshot(working, path, r.secrets) + for payloadID, payload := range model.sourcePayloads { + if payload.kind != historySourceApplicationPayload { + continue } + application := payload.application + before := valueSnapshot(working, path, r.secrets) applyHistoryApplication(&working, application, replayIndex) + selected, relevant := historySelectionForApplication(model, selections, historyPhaseSources, historyPayloadRef{phase: historyPhaseSources, id: payloadID}) if !relevant { continue } @@ -69,37 +79,38 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { explanation.History = append(explanation.History, ValueChange{ Before: before, After: after, - Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup), + Origin: cloneOrigin(selected.attribution.origin), }) } - appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets) + appendInheritanceExplanation(&explanation, &working, path, 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. - decodedInput := r.preCLI - decodedOrigins := r.preCLIOrigins - replayed, _ := documentAtPath(working, path) - decoded, _ := documentAtPathWithLookup(decodedInput, path, r.immutableLookup) - if !configurationValuesEqual(replayed, decoded) { - origin := cloneOrigin(decodedOrigins[path.String()]) - origin.Operation = ChangeDecoding - explanation.History = append(explanation.History, ValueChange{ - Before: valueSnapshot(working, path, r.secrets), - After: valueSnapshotWithLookup(decodedInput, path, r.secrets, r.immutableLookup), - Origin: origin, - Reasons: []string{"applied retained defaults and canonical typed decoding"}, - }) + if checkpoint := model.preCLICheckpoint; checkpoint != nil { + selection, selected := historySelectionForPayload(selections, historyPayloadRef{phase: historyPhasePreCLI, id: 0}) + replayed, _ := documentAtPath(working, path) + decoded, _ := documentAtPathWithLookup(checkpoint.root, path, 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), + Origin: origin, + Reasons: reasons, + }) + } + working = document.Clone(checkpoint.root) } - working = document.Clone(decodedInput) - for _, application := range r.postDecodeApps { - operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup) - var before ValueSnapshot - if relevant { - before = valueSnapshot(working, path, r.secrets) + for payloadID, payload := range model.cliPayloads { + if payload.kind != historySourceApplicationPayload { + continue } - applyHistoryApplication(&working, application, replayIndex) + applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID} + before := valueSnapshot(working, path, r.secrets) + applyHistoryApplication(&working, payload.application, replayIndex) + selected, relevant := historySelectionForApplication(model, selections, historyPhaseCLI, applicationRef) if !relevant { continue } @@ -110,47 +121,45 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { explanation.History = append(explanation.History, ValueChange{ Before: before, After: after, - Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup), + Origin: cloneOrigin(selected.attribution.origin), }) } - replayed, _ = documentAtPath(working, path) - effectiveInput, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup) - if !configurationValuesEqual(replayed, effectiveInput) { - origin := cloneOrigin(r.inputOrigins[path.String()]) - origin.Operation = ChangeDecoding - explanation.History = append(explanation.History, ValueChange{ - Before: valueSnapshot(working, path, r.secrets), - After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup), - Origin: origin, - Reasons: []string{"applied the typed CLI assignment"}, - }) + if checkpoint := model.postCLICheckpoint; checkpoint != nil { + selection, selected := historySelectionForPayload(selections, historyPayloadRef{phase: historyPhasePostCLI, id: 0}) + replayed, _ := documentAtPath(working, path) + effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, path, 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), + Origin: origin, + Reasons: reasons, + }) + } + working = document.Clone(checkpoint.root) } - working = document.Clone(r.input) - for _, group := range r.resolutionReplay { - if group.coveringRecordID < 0 || group.coveringRecordID >= len(r.resolution) { - continue + if model.resolutionPayload != nil { + for _, group := range model.resolutionPayload.replayGroups { + selection, selected := historySelectionForResolutionGroup(selections, group) + if !selected || group.coveringRecordID < 0 || group.coveringRecordID >= len(model.resolutionPayload.snapshots) { + continue + } + covering := model.resolutionPayload.snapshots[group.coveringRecordID] + before := valueSnapshot(working, path, r.secrets) + replaceHistoryPath(&working, group.change.Path, covering.after) + after := valueSnapshot(working, path, r.secrets) + if !before.Present && !after.Present { + continue + } + explanation.History = append(explanation.History, ValueChange{ + Before: before, + After: after, + Origin: cloneOrigin(selection.attribution.origin), + Reasons: append([]string(nil), group.change.Reasons...), + Dependencies: clonePaths(group.change.Dependencies), + }) } - covering := r.resolution[group.coveringRecordID] - relevant := pathsIntersect(group.change.Path, path) - var before ValueSnapshot - if relevant { - before = valueSnapshot(working, path, r.secrets) - } - replaceHistoryPath(&working, group.change.Path, covering.after) - if !relevant { - continue - } - after := valueSnapshot(working, path, r.secrets) - if !before.Present && !after.Present { - continue - } - explanation.History = append(explanation.History, ValueChange{ - Before: before, - After: after, - Origin: cloneOrigin(group.change.Origin), - Reasons: append([]string(nil), group.change.Reasons...), - Dependencies: clonePaths(group.change.Dependencies), - }) } if !explanation.Effective.Present && len(explanation.History) == 0 { @@ -161,3 +170,63 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { } return explanation, true } + +func historySelectionForPayload(selections []historySelection, ref historyPayloadRef) (historySelection, bool) { + for _, selection := range selections { + if selection.payload == ref { + return selection, true + } + } + return historySelection{}, false +} + +func historySelectionForApplication(model *historyModel, selections []historySelection, phase historyPhase, applicationRef historyPayloadRef) (historySelection, bool) { + var selected historySelection + found := false + if direct, ok := historySelectionForPayload(selections, applicationRef); ok { + selected, found = direct, true + } + var payloads []historySourcePayload + switch phase { + case historyPhaseSources: + payloads = model.sourcePayloads + case historyPhaseCLI: + payloads = model.cliPayloads + default: + return selected, found + } + for payloadID, candidate := range payloads { + if candidate.kind != historySourceOverlayPayload || !candidate.hasApplicationRef || candidate.applicationRef != applicationRef { + continue + } + if operation, ok := historySelectionForPayload(selections, historyPayloadRef{phase: phase, id: payloadID}); ok { + selected, found = operation, true + } + } + return selected, found +} + +func checkpointExplanationAttribution(selection historySelection) (Origin, []string) { + if selection.hasAttribution { + return cloneOrigin(selection.attribution.origin), append([]string(nil), selection.attribution.reasons...) + } + return Origin{Operation: ChangeDecoding}, nil +} + +func historySelectionForResolutionGroup(selections []historySelection, group resolutionReplayGroup) (historySelection, bool) { + var fallback historySelection + found := false + for _, recordID := range group.originalRecordIDs { + selection, ok := historySelectionForPayload(selections, historyPayloadRef{phase: historyPhaseResolution, id: recordID}) + if !ok { + continue + } + if recordID == group.coveringRecordID { + return selection, true + } + if !found { + fallback, found = selection, true + } + } + return fallback, found +} diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go index 489f0fe..d4023ba 100644 --- a/internal/engine/explain_test.go +++ b/internal/engine/explain_test.go @@ -389,6 +389,11 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) { if last.Origin.Layer != info.Origin.Layer { t.Fatal("decoding lost effective origin") } + for _, metadataChange := range result.Metadata().History(path) { + if metadataChange.Origin.Operation == ChangeDecoding { + t.Fatal("typed reconciliation leaked into metadata history") + } + } } }) } @@ -535,6 +540,25 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) if overlap && count != 1 { t.Fatalf("overlapping records applied %d times", count) } + if overlap && path.String() == "/m" { + metadataHistory := result.Metadata().History(path) + var sharedResolutionOrigin bool + for _, metadataChange := range metadataHistory { + if metadataChange.Origin.Layer != LayerResolution { + continue + } + for _, explanationChange := range x.History { + if explanationChange.Origin.Layer == metadataChange.Origin.Layer && + explanationChange.Origin.Operation == metadataChange.Origin.Operation && + reflect.DeepEqual(explanationChange.Origin.Locations, metadataChange.Origin.Locations) { + sharedResolutionOrigin = true + } + } + } + if !sharedResolutionOrigin { + t.Fatalf("metadata and explanation disagree on overlapping resolution attribution: metadata %#v, explanation %#v", metadataHistory, x.History) + } + } last := x.History[len(x.History)-1] if last.After != x.Effective { t.Fatalf("incorrect replay for %s: %#v", path, x) diff --git a/internal/engine/history_cli.go b/internal/engine/history_cli.go index 189f650..e8b0d28 100644 --- a/internal/engine/history_cli.go +++ b/internal/engine/history_cli.go @@ -76,14 +76,17 @@ func (builder *historyBuilder) observeCLIEvidence(applications []historyApplicat } index := new(mappingIndex) for item := 0; item < count; item++ { + var applicationRef historyPayloadRef + hasApplication := false if item < len(applications) { application := applications[item] - payload := builder.addCLIPayload(&application, nil) + applicationRef = builder.addCLIPayload(&application, nil) + hasApplication = true walkHistoryApplication(application, func(_, effective Path, node *document.Node) { origin := sourceApplicationOrigin(application, node) attribution := builder.addAttribution(origin, nil, nil) builder.addObservation(historyObservation{ - path: effective, phase: historyPhaseCLI, payload: payload, + path: effective, phase: historyPhaseCLI, payload: applicationRef, reportedPath: effective, role: historyRoleContribution, afterPresent: true, sensitive: node.Sensitive, attributionRef: attribution, @@ -94,6 +97,11 @@ func (builder *historyBuilder) observeCLIEvidence(applications []historyApplicat if item < len(records) { record := records[item] payload := builder.addCLIPayload(nil, &record) + if hasApplication { + owned := &builder.model.cliPayloads[payload.id] + owned.applicationRef = applicationRef + owned.hasApplicationRef = true + } attribution := builder.addAttribution(record.change.Origin, nil, nil) for _, displaced := range record.displaced { builder.addObservation(historyObservation{ diff --git a/internal/engine/history_inheritance.go b/internal/engine/history_inheritance.go index 8f31d90..9b741e7 100644 --- a/internal/engine/history_inheritance.go +++ b/internal/engine/history_inheritance.go @@ -282,6 +282,13 @@ func resolveInheritanceAttribution(operation inheritanceOperation, destination P return selections } +func operationPathForQuery(root, query Path) Path { + if pathContains(query, root) { + return root + } + return query +} + func inheritanceChangesForQuery(query Path, operation inheritanceOperation) []Change { if operation.control != nil { for _, selection := range resolveInheritanceAttribution(operation, query) { diff --git a/internal/engine/history_model.go b/internal/engine/history_model.go index 7e34675..7149bd0 100644 --- a/internal/engine/history_model.go +++ b/internal/engine/history_model.go @@ -130,6 +130,9 @@ func (builder *historyBuilder) addObservation(observation historyObservation) in case historyRoleDefinition, historyRoleContribution, historyRoleCompositeDefinition, historyRoleCheckpoint: pointer := observation.path.String() builder.model.observationsByPointer[pointer] = append(builder.model.observationsByPointer[pointer], observationID) + if observation.role == historyRoleContribution { + builder.model.reportedPathIndex.insert(pointer, observationID) + } case historyRoleOperation, historyRoleControl: builder.model.reportedPathIndex.insert(observation.reportedPath.String(), observationID) } diff --git a/internal/engine/history_select.go b/internal/engine/history_select.go index 4c292c0..e90a91f 100644 --- a/internal/engine/history_select.go +++ b/internal/engine/history_select.go @@ -36,6 +36,9 @@ func (model *historyModel) selectHistory(path Path) []historySelection { model.reportedPathIndex.visitAncestors(path.String(), func(observationID int) { candidateSet[observationID] = struct{}{} }) + model.reportedPathIndex.visitDescendants(path.String(), func(observationID int) { + candidateSet[observationID] = struct{}{} + }) candidates := make([]int, 0, len(candidateSet)) for observationID := range candidateSet { candidates = append(candidates, observationID) @@ -46,7 +49,32 @@ func (model *historyModel) selectHistory(path Path) []historySelection { selectionByPayload := make(map[historyPayloadRef]int) for _, observationID := range candidates { observation := model.observations[observationID] - if observation.role == historyRoleInventory || !matchesPathShape(path, observation.path) { + if observation.role == historyRoleInventory { + continue + } + reportedPath := observation.reportedPath + attributionRef := observation.attributionRef + if observation.role == historyRoleContribution { + if !historyObservationMatchesQuery(path, observation.path) { + continue + } + application, found := model.applicationPayload(observation.payload) + if !found { + continue + } + if pathContains(observation.path, path) && len(observation.path.segments) < len(path.segments) { + local, ok := historyApplicationLocalPath(application, path) + if !ok { + continue + } + if _, present := documentAtPathWithLookup(application.value, local, model.documentLookup); !present { + continue + } + } + if operationPath, relevant := applicationOperationPath(application, path, model.documentLookup); relevant { + reportedPath = operationPath + } + } else if !historyObservationMatchesQuery(path, observation.path) { continue } selectionIndex, found := selectionByPayload[observation.payload] @@ -57,10 +85,10 @@ func (model *historyModel) selectHistory(path Path) []historySelection { path: path.clone(), phase: observation.phase, payload: observation.payload, - reportedPath: observation.reportedPath.clone(), + reportedPath: reportedPath.clone(), role: observation.role, } - if attribution, ok := model.resolveAttribution(observation.attributionRef); ok { + if attribution, ok := model.resolveAttribution(attributionRef); ok { selection.attribution = attribution selection.hasAttribution = true } @@ -88,6 +116,13 @@ func (model *historyModel) selectHistory(path Path) []historySelection { return selections } +func historyObservationMatchesQuery(query, observed Path) bool { + if matchesPathShape(query, observed) { + return true + } + return pathContains(query, observed) && pathShapePrefixMatches(query, observed) +} + func (model *historyModel) hasObservedShape(path Path) bool { if model == nil || !path.valid() { return false @@ -187,6 +222,32 @@ func (model *historyModel) resolveAttribution(ref historyAttributionRef) (histor }, true } +func (model *historyModel) applicationPayload(ref historyPayloadRef) (historyApplication, bool) { + if model == nil || ref.id < 0 { + return historyApplication{}, false + } + var payloads []historySourcePayload + switch ref.phase { + case historyPhaseSources: + payloads = model.sourcePayloads + case historyPhaseCLI: + payloads = model.cliPayloads + default: + return historyApplication{}, false + } + if ref.id >= len(payloads) || payloads[ref.id].kind != historySourceApplicationPayload { + return historyApplication{}, false + } + return payloads[ref.id].application, true +} + +func pathShapePrefixMatches(left, right Path) bool { + length := min(len(left.segments), len(right.segments)) + leftPrefix := Path{segments: clonePathSegments(left.segments[:length], 0)} + rightPrefix := Path{segments: clonePathSegments(right.segments[:length], 0)} + return matchesPathShape(leftPrefix, rightPrefix) +} + func metadataHistoryRole(role historyRole) bool { switch role { case historyRoleDefinition, historyRoleCompositeDefinition, historyRoleOperation, historyRoleControl: diff --git a/internal/engine/history_sequence_test.go b/internal/engine/history_sequence_test.go index 6eb1ea7..630f0b7 100644 --- a/internal/engine/history_sequence_test.go +++ b/internal/engine/history_sequence_test.go @@ -282,6 +282,7 @@ func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T metadata: metadata, baseline: &document.Node{Kind: document.Mapping}, input: composed.root, preCLI: composed.root, final: composed.root, applications: composed.applications, } + inspection.metadata.historyModel = sourceHistoryModelForInspection(inspection.baseline, composed.root, composed.origins, composed.applications, composed.overlays) inspection.secrets = classifySecretPaths(nil, func(candidate Path) bool { return reflect.DeepEqual(candidate, path) }, metadata, composed.root, composed.applications) @@ -329,6 +330,7 @@ func TestReplacedProjectedSensitiveRecordRemainsRedacted(t *testing.T) { metadata: metadata, baseline: &document.Node{Kind: document.Mapping}, input: final, preCLI: final, final: final, applications: applications, } + inspection.metadata.historyModel = sourceHistoryModelForInspection(inspection.baseline, final, nil, applications, nil) inspection.secrets = classifySecretPaths(nil, func(candidate Path) bool { return reflect.DeepEqual(candidate, path) }, metadata, final, applications) @@ -345,6 +347,16 @@ func TestReplacedProjectedSensitiveRecordRemainsRedacted(t *testing.T) { } } +func sourceHistoryModelForInspection(baseline, final *document.Node, origins map[string]Origin, applications []historyApplication, records []overlayRecord) *historyModel { + builder := newHistoryBuilder() + builder.observeBaseline(baseline) + builder.setScratch(baseline) + builder.observeSourceEvidence(final, origins, applications, records) + builder.model.final = final + builder.registerDocumentRoot(final) + return builder.freeze() +} + func TestMappedUpsertProjectsSensitiveReplacementEvidence(t *testing.T) { lowerRoot := parseCompositionDocument(t, "lower", `records: - id: first diff --git a/internal/engine/history_source.go b/internal/engine/history_source.go index db6e05e..e192e42 100644 --- a/internal/engine/history_source.go +++ b/internal/engine/history_source.go @@ -19,12 +19,14 @@ const ( // historySourcePayload keeps source values and replacement facts under the // shared model's ownership. Application documents remain shared when immutable. type historySourcePayload struct { - kind historySourcePayloadKind - application historyApplication - overlay overlayRecord - path Path - origin Origin - contributions []historyPayloadRef + kind historySourcePayloadKind + application historyApplication + overlay overlayRecord + applicationRef historyPayloadRef + hasApplicationRef bool + path Path + origin Origin + contributions []historyPayloadRef } func (builder *historyBuilder) observeBaseline(root *document.Node) { @@ -53,11 +55,22 @@ func (builder *historyBuilder) observeSourceEvidence(root *document.Node, origin if len(applications) == 0 && len(records) == 0 { return } + applicationsBySource := make(map[string]historyPayloadRef) + applicationsByLayer := make(map[Layer][]historyPayloadRef) for index := range applications { application := applications[index] payload := builder.addSourcePayload(&application, nil) + applicationsByLayer[application.layer] = append(applicationsByLayer[application.layer], payload) walkHistoryApplication(application, func(_, effective Path, node *document.Node) { + if node.Location.SourceID != "" { + applicationsBySource[node.Location.SourceID] = payload + } origin := sourceApplicationOrigin(application, node) + for _, location := range origin.Locations { + if location.SourceID != "" { + applicationsBySource[location.SourceID] = payload + } + } attribution := builder.addAttribution(origin, nil, nil) builder.addObservation(historyObservation{ path: effective, phase: historyPhaseSources, payload: payload, @@ -70,6 +83,12 @@ func (builder *historyBuilder) observeSourceEvidence(root *document.Node, origin for index := range records { record := records[index] payload := builder.addSourcePayload(nil, &record) + applicationRef, hasApplication := sourceApplicationForRecord(record, applicationsBySource, applicationsByLayer) + if hasApplication { + owned := &builder.model.sourcePayloads[payload.id] + owned.applicationRef = applicationRef + owned.hasApplicationRef = true + } for displacedIndex, displaced := range record.displaced { definitionPayload := payload definitionPayload.substep = displacedIndex + 1 @@ -119,6 +138,19 @@ func (builder *historyBuilder) observeSourceEvidence(root *document.Node, origin }) } +func sourceApplicationForRecord(record overlayRecord, bySource map[string]historyPayloadRef, byLayer map[Layer][]historyPayloadRef) (historyPayloadRef, bool) { + for _, location := range record.change.Origin.Locations { + if reference, found := bySource[location.SourceID]; found && location.SourceID != "" { + return reference, true + } + } + applications := byLayer[record.change.Origin.Layer] + if len(applications) == 1 { + return applications[0], true + } + return historyPayloadRef{}, false +} + func (builder *historyBuilder) addSourcePayload(application *historyApplication, overlay *overlayRecord) historyPayloadRef { payload := builder.nextPayload(historyPhaseSources) if payload.id != len(builder.model.sourcePayloads) { diff --git a/internal/engine/history_source_test.go b/internal/engine/history_source_test.go index cfdf350..96ddf5a 100644 --- a/internal/engine/history_source_test.go +++ b/internal/engine/history_source_test.go @@ -40,6 +40,8 @@ func TestHistorySourceObservationsPreserveRawAndCompositeDefinitions(t *testing. builder := newHistoryBuilder() builder.observeBaseline(baseline) builder.observeSourceEvidence(assembly.current.root, assembly.current.origins, assembly.current.applications, assembly.current.overlays) + builder.model.final = assembly.current.root + builder.registerDocumentRoot(assembly.current.root) model := builder.freeze() defaultPath := Keys("defaults", "nullable") @@ -87,6 +89,30 @@ func TestHistorySourceObservationsPreserveRawAndCompositeDefinitions(t *testing. if emptySummary == nil || len(emptySummary.contributions) != 2 || emptySummary.contributions[0].id != 0 || emptySummary.contributions[1].id != 1 { t.Fatalf("empty mapping summary contribution references = %#v", emptySummary) } + + metadata := Metadata{historyModel: model} + metadataHistory := metadata.History(Keys("section", "empty")) + var compositeSources []string + for _, change := range metadataHistory { + if len(change.Origin.Locations) > 1 { + compositeSources = locationSourceIDs(change.Origin.Locations) + } + } + inspection := &Inspection{ + metadata: metadata, baseline: baseline, final: assembly.current.root, + immutableLookup: buildDocumentLookup(assembly.current.root), + } + explanation, explained := inspection.Explain(Keys("section", "empty")) + var contributionSources []string + for _, change := range explanation.History { + if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerOverlay { + contributionSources = append(contributionSources, locationSourceIDs(change.Origin.Locations)...) + } + } + if !explained || !reflect.DeepEqual(compositeSources, []string{"base", "overlay"}) || + !reflect.DeepEqual(contributionSources, []string{"base", "overlay"}) { + t.Fatalf("metadata composite attribution and explanation contributions differ: metadata %#v, explanation %#v", metadataHistory, explanation.History) + } } func TestHistorySourceReplacementTracksConcreteBeforeAndAfterShapes(t *testing.T) { diff --git a/internal/engine/inheritance_explain.go b/internal/engine/inheritance_explain.go index a8be72f..dcc3a45 100644 --- a/internal/engine/inheritance_explain.go +++ b/internal/engine/inheritance_explain.go @@ -2,64 +2,61 @@ package engine import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" -func appendInheritanceExplanation(explanation *Explanation, working **document.Node, query Path, operations []inheritanceOperation, secrets map[string]bool) { - for _, operation := range operations { - selectionPath := query - if operation.transfer != nil { - selectionPath = operationPathForQuery(operation.transfer.to, query) - } - selections := resolveInheritanceAttribution(operation, selectionPath) - if operation.control != nil { - control := selectInheritanceEvidence(selections, inheritanceEvidenceControl, inheritanceEvidenceControl) - if control == nil || !pathsIntersect(operation.control.path, query) { +func appendInheritanceExplanation(explanation *Explanation, working **document.Node, query Path, model *historyModel, selections []historySelection, secrets map[string]bool) { + if model == nil { + return + } + for _, payload := range model.inheritancePayloads { + for operationIndex, operation := range payload.operations { + if operation.control != nil { + ref := historyPayloadRef{phase: historyPhaseInheritance, id: 0, substep: operationIndex * 2} + selection, selected := historySelectionForPayload(selections, ref) + if !selected { + continue + } + before := valueSnapshot(*working, query, secrets) + replaceHistoryPath(working, operation.control.path, nil) + after := valueSnapshot(*working, query, secrets) + appendInheritanceChange(explanation, selection, before, after) continue } + if operation.transfer == nil { + continue + } + parentRef := historyPayloadRef{phase: historyPhaseInheritance, id: 0, substep: operationIndex * 2} + childRef := historyPayloadRef{phase: historyPhaseInheritance, id: 0, substep: operationIndex*2 + 1} + parentSelection, hasParent := historySelectionForPayload(selections, parentRef) + childSelection, hasChild := historySelectionForPayload(selections, childRef) before := valueSnapshot(*working, query, secrets) - replaceHistoryPath(working, operation.control.path, nil) - after := valueSnapshot(*working, query, secrets) - explanation.History = append(explanation.History, ValueChange{ - Before: before, After: after, Origin: cloneOrigin(control.origin), - Dependencies: clonePaths(control.dependencies), - }) - continue - } - if operation.transfer == nil { - continue - } - before := valueSnapshot(*working, query, secrets) - replaceHistoryPath(working, operation.transfer.to, operation.parent) - // A record keeps its own identity throughout inheritance. Restore it - // before taking enclosing snapshots as well as direct field snapshots. - for _, field := range operation.identityFields { - identity, _ := documentAtPath(operation.effective, Keys(field)) - replaceHistoryPath(working, operation.transfer.to.Key(field), identity) - } - afterParent := valueSnapshot(*working, query, secrets) - parent := selectInheritanceEvidence(selections, inheritanceEvidenceParent, inheritanceEvidenceParent) - if pathsIntersect(operation.transfer.to, query) && parent != nil { - explanation.History = append(explanation.History, ValueChange{ - Before: before, After: afterParent, Origin: cloneOrigin(parent.origin), - Dependencies: clonePaths(parent.dependencies), - }) - } - replaceHistoryPath(working, operation.transfer.to, operation.effective) - if !pathsIntersect(operation.transfer.to, query) { - continue - } - afterChild := valueSnapshot(*working, query, secrets) - child := selectInheritanceEvidence(selections, inheritanceEvidenceChild, inheritanceEvidenceRemoval, inheritanceEvidenceDisplaced) - if child != nil && (parent == nil || len(selections) > 1) { - explanation.History = append(explanation.History, ValueChange{ - Before: afterParent, After: afterChild, Origin: cloneOrigin(child.origin), - Dependencies: clonePaths(child.dependencies), - }) + replaceHistoryPath(working, operation.transfer.to, operation.parent) + // A record keeps its own identity throughout inheritance. Restore it + // before taking enclosing snapshots as well as direct field snapshots. + for _, field := range operation.identityFields { + identity, _ := documentAtPath(operation.effective, Keys(field)) + replaceHistoryPath(working, operation.transfer.to.Key(field), identity) + } + afterParent := valueSnapshot(*working, query, secrets) + if hasParent { + appendInheritanceChange(explanation, parentSelection, before, afterParent) + } + replaceHistoryPath(working, operation.transfer.to, operation.effective) + afterChild := valueSnapshot(*working, query, secrets) + if hasChild { + appendInheritanceChange(explanation, childSelection, afterParent, afterChild) + } } } } -func operationPathForQuery(root, query Path) Path { - if pathContains(query, root) { - return root +func appendInheritanceChange(explanation *Explanation, selection historySelection, before, after ValueSnapshot) { + if !selection.hasAttribution { + return } - return query + explanation.History = append(explanation.History, ValueChange{ + Before: before, + After: after, + Origin: cloneOrigin(selection.attribution.origin), + Reasons: append([]string(nil), selection.attribution.reasons...), + Dependencies: clonePaths(selection.attribution.dependencies), + }) } diff --git a/internal/engine/metadata.go b/internal/engine/metadata.go index c0768b5..ef8e2d2 100644 --- a/internal/engine/metadata.go +++ b/internal/engine/metadata.go @@ -121,8 +121,17 @@ func matchesPathShape(query, candidate Path) bool { return false } if actual.kind == pathSegmentIndex { - index, ok := schemaIndex(segment) - if !ok || index != actual.index { + switch segment.kind { + case pathSegmentIndex: + if segment.index != actual.index { + return false + } + case pathSegmentParsed: + index, ok := schemaIndex(segment) + if !ok || index != actual.index { + return false + } + default: return false } } else if segment.kind == pathSegmentIndex {