diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 31e1c2f..df2dc0b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -219,6 +219,8 @@ Do not assert an incidental struct layout or a fixed number of internal calls. ## Stage 6 — Refresh effective metadata using the matched entry record +**Status:** Complete. Focused resolution, metadata, validation, and secret checks passed. + **Scope:** presence, origins, and post-resolution diagnostic locations. 1. In `applyResolutionMetadata` and `refreshResolutionFields`, seed final fields diff --git a/internal/engine/load_metadata.go b/internal/engine/load_metadata.go index 65ea910..efd6ea5 100644 --- a/internal/engine/load_metadata.go +++ b/internal/engine/load_metadata.go @@ -141,6 +141,15 @@ func cloneMetadata(metadata Metadata) Metadata { func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entry resolutionInput) { finalPaths := make(map[string]*document.Node) indexDocument(resolved.final, Path{}, finalPaths) + fields := make(map[string]FieldInfo, len(finalPaths)) + walkDocument(resolved.final, Path{}, func(path Path, _ *document.Node) { + if prior, matched := resolved.correspondence.entryPath(path); matched { + if info, found := entry.lookup(prior); found { + fields[path.String()] = cloneFieldInfo(info) + } + } + }) + metadata.fields = fields snapshotCoverage, snapshotPointers := newResolutionCoverageIndexFromSnapshots(resolved.snapshots) var deltaCoverage pathPrefixIndex snapshotIDs := make([]int, len(resolved.deltas)) @@ -163,7 +172,7 @@ func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entr } applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs) - refreshResolutionFields(metadata.fields, finalPaths, resolved.final, entry, resolved.snapshots, snapshotCoverage) + refreshResolutionFields(metadata.fields, finalPaths, resolved.final, entry, resolved.snapshots, snapshotCoverage, resolved.correspondence) } func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) { @@ -192,7 +201,7 @@ func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolu } } -func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, final *document.Node, entry resolutionInput, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex) { +func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, final *document.Node, entry resolutionInput, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex, correspondence resolutionCorrespondence) { for pointer := range fields { if _, ok := finalPaths[pointer]; !ok { delete(fields, pointer) @@ -200,7 +209,9 @@ func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string] } walkDocument(final, Path{}, func(path Path, node *document.Node) { pointer := path.String() - entryInfo, entryFound := entry.lookup(path) + prior, matched := correspondence.entryPath(path) + entryInfo, entryFound := entry.lookup(prior) + entryFound = entryFound && matched if info, ok := fields[pointer]; ok { info.Explicit = entryFound && entryInfo.Explicit info.Null = node.Kind == document.Null diff --git a/internal/engine/override_context.go b/internal/engine/override_context.go index 1dbe440..3072f60 100644 --- a/internal/engine/override_context.go +++ b/internal/engine/override_context.go @@ -162,6 +162,13 @@ func documentWithMetadataLocations(root *document.Node, metadata Metadata, fallb } var lookup *documentLookup fallbackLocation := func(path Path) (document.Location, bool) { + if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil { + var matched bool + path, matched = model.resolutionPayload.correspondence.entryPath(path) + if !matched { + return document.Location{}, false + } + } if lookup == nil { lookup = buildDocumentLookup(fallbackRoot) } @@ -255,6 +262,13 @@ 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 8892f77..3d5b80d 100644 --- a/internal/engine/resolution_compare_test.go +++ b/internal/engine/resolution_compare_test.go @@ -203,3 +203,65 @@ func TestRegisteredResolutionFallbackAndHelperTarget(t *testing.T) { t.Fatalf("diagnostics = %#v", failure.Diagnostics()) } } + +func TestRegisteredResolutionMetadataFollowsEntryIdentity(t *testing.T) { + type item struct { + ID string `yaml:"id"` + Enabled bool `yaml:"enabled"` + Count int `yaml:"count"` + } + type config struct { + Items []item `yaml:"items"` + } + entryFixed := false + definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error { + entryFixed = r.IsExplicit(Keys("items").Index(0).Key("enabled")) && !r.IsExplicit(Keys("items").Index(1).Key("enabled")) + value.Items[0], value.Items[1] = value.Items[1], value.Items[0] + value.Items = append(value.Items, item{ID: "new"}) + r.RecordNormalization(Keys("items"), "reorder and add") + return nil + }}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + result, err := testLoad(Bytes("records", []byte("items: [{id: a, enabled: false}, {id: b, count: 0}]\n")), Options[config]{Definition: definition}) + if err != nil || !entryFixed { + t.Fatalf("load = %#v, fixed entry = %t, error = %v", result, entryFixed, err) + } + for _, check := range []struct { + index int + key string + explicit bool + }{{0, "enabled", false}, {0, "count", true}, {1, "enabled", true}, {1, "count", false}, {2, "id", false}} { + info, ok := result.Metadata().Lookup(Keys("items").Index(check.index).Key(check.key)) + if !ok || info.Explicit != check.explicit || info.Origin.Layer != LayerResolution { + t.Errorf("item %d %s = %#v, %t", check.index, check.key, info, ok) + } + } +} + +func TestRegisteredResolutionValidationLocationFollowsMovedRecord(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 + }, + Validate: func(_ *config, v *Validation) { v.Error(Keys("items").Index(0).Key("value"), "invalid") }, + }, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}) + _, err := testLoad(Bytes("records", []byte("items:\n - id: a\n value: first\n - id: b\n value: second\n")), Options[config]{Definition: definition}) + var failure *LoadError + if !errors.As(err, &failure) { + t.Fatalf("error = %v", err) + } + for _, diagnostic := range failure.Diagnostics() { + if diagnostic.Path.String() == "/items/0/value" && diagnostic.Primary != nil && diagnostic.Primary.Line == 5 { + return + } + } + t.Fatalf("diagnostics = %#v", failure.Diagnostics()) +}