diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index df2dc0b..d4b8066 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -247,6 +247,8 @@ meanings. History-based location fallback becomes fully usable in Stage 7. ## Stage 7 — Select history through the correct entry coordinate +**Status:** Complete. Focused history and matched-location checks passed. + **Scope:** shared history query selection and metadata history projection. 1. Add a query-local coordinate description to the existing history selection diff --git a/internal/engine/history_select.go b/internal/engine/history_select.go index b86d030..cdcd012 100644 --- a/internal/engine/history_select.go +++ b/internal/engine/history_select.go @@ -26,21 +26,43 @@ type historySensitivityCandidate struct { classify bool } +type historyQueryCoordinates struct { + final Path + entry Path + hasEntry bool +} + +func (model *historyModel) queryCoordinates(path Path) historyQueryCoordinates { + coordinate := historyQueryCoordinates{final: path.clone(), entry: path.clone(), hasEntry: true} + if model != nil && model.resolutionPayload != nil { + coordinate.entry, coordinate.hasEntry = model.resolutionPayload.correspondence.entryPath(path) + } + return coordinate +} + func (model *historyModel) selectHistory(path Path) []historySelection { - if model == nil || !path.valid() || !model.hasObservedShape(path) { + if model == nil || !path.valid() { + return nil + } + coordinate := model.queryCoordinates(path) + if !model.hasObservedShape(path) && (!coordinate.hasEntry || !model.hasObservedShape(coordinate.entry)) { return nil } candidateSet := make(map[int]struct{}) - for _, observationID := range model.observationsByPointer[path.String()] { - candidateSet[observationID] = struct{}{} + collect := func(query Path) { + for _, observationID := range model.observationsByPointer[query.String()] { + candidateSet[observationID] = struct{}{} + } + model.affectedPathIndex.visitDescendants(query.String(), func(observationID int) { candidateSet[observationID] = struct{}{} }) + } + collect(path) + if coordinate.hasEntry { + collect(coordinate.entry) } // Producers register all affected before/after paths. Exact facts cover // ancestor replacements; descendants cover operations within an enclosing // query. Logical operations are still deduplicated by payload below. - model.affectedPathIndex.visitDescendants(path.String(), func(observationID int) { - candidateSet[observationID] = struct{}{} - }) candidates := make([]int, 0, len(candidateSet)) for observationID := range candidateSet { candidates = append(candidates, observationID) @@ -54,18 +76,30 @@ func (model *historyModel) selectHistory(path Path) []historySelection { if observation.role == historyRoleInventory { continue } + query := path + if observation.phase != historyPhaseResolution { + if !coordinate.hasEntry { + continue + } + query = coordinate.entry + } else if observation.beforePresent && !observation.afterPresent { + if !coordinate.hasEntry { + continue + } + query = coordinate.entry + } reportedPath := observation.reportedPath attributionRef := observation.attributionRef if observation.role == historyRoleContribution { - if !historyObservationMatchesQuery(path, observation.path) { + if !historyObservationMatchesQuery(query, 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 pathContains(observation.path, query) && len(observation.path.segments) < len(query.segments) { + local, ok := historyApplicationLocalPath(application, query) if !ok { continue } @@ -73,12 +107,18 @@ func (model *historyModel) selectHistory(path Path) []historySelection { continue } } - if operationPath, relevant := applicationOperationPath(application, path, model.documentLookup); relevant { + if operationPath, relevant := applicationOperationPath(application, query, model.documentLookup); relevant { reportedPath = operationPath } - } else if !historyObservationMatchesQuery(path, observation.path) { + } else if !historyObservationMatchesQuery(query, observation.path) { continue } + if query.String() != path.String() && pathContains(query, reportedPath) { + suffix := reportedPath.segments[len(query.segments):] + reportedPath = Path{segments: append(clonePathSegments(path.segments, len(suffix)), suffix...)} + } else if query.String() != path.String() && pathContains(reportedPath, query) && len(reportedPath.segments) >= len(query.segments)-1 { + reportedPath = path.clone() + } selectionIndex, found := selectionByPayload[observation.payload] if !found { selectionIndex = len(selections) diff --git a/internal/engine/override_context.go b/internal/engine/override_context.go index 3072f60..e54f33f 100644 --- a/internal/engine/override_context.go +++ b/internal/engine/override_context.go @@ -262,13 +262,6 @@ func metadataFieldLocation(metadata Metadata, path Path, origin Origin) (documen return location, true } if model := metadata.historyEvidence(); model != nil { - if model.resolutionPayload != nil { - var matched bool - path, matched = model.resolutionPayload.correspondence.entryPath(path) - if !matched { - return document.Location{}, false - } - } location, ok := model.latestHistoricalLocation(path) if ok { return document.Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}, true diff --git a/internal/engine/resolution_compare_test.go b/internal/engine/resolution_compare_test.go index 3d5b80d..e6c1a95 100644 --- a/internal/engine/resolution_compare_test.go +++ b/internal/engine/resolution_compare_test.go @@ -265,3 +265,48 @@ func TestRegisteredResolutionValidationLocationFollowsMovedRecord(t *testing.T) } t.Fatalf("diagnostics = %#v", failure.Diagnostics()) } + +func TestRegisteredResolutionHistoryFollowsMatchedSource(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] + value.Items = append(value.Items, item{ID: "new", Value: "added"}) + r.RecordNormalization(Keys("items"), "reorder and add") + return nil + }}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items:\n - id: a\n value: first\n - id: b\n value: second\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + for _, check := range []struct{ index, line int }{{0, 5}, {1, 3}} { + path := Keys("items").Index(check.index).Key("value") + if location, ok := result.history.latestHistoricalLocation(path); !ok || location.Line != check.line { + t.Errorf("historical location for %s = %#v, %t", path.String(), location, ok) + } + history := result.Metadata().History(path) + found := false + for _, change := range history { + for _, location := range change.Origin.Locations { + if location.Line == check.line { + found = true + } else if location.Line == 3 || location.Line == 5 { + t.Errorf("wrong record in %s: %#v", path.String(), history) + } + } + } + if !found { + t.Errorf("missing source line %d in %#v", check.line, history) + } + } + for _, change := range result.Metadata().History(Keys("items").Index(2).Key("value")) { + if change.Origin.Layer == LayerMain { + t.Fatalf("added record inherited input: %#v", change) + } + } +}