diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4632b81..f59f2a7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -81,7 +81,7 @@ performance guarantees, resource caps, or benchmark gates. ## Stage 1 — Factor reusable effective-path indexing -Status: not started. Dependencies: none. +Status: complete (2026-09-12). Dependencies: none. Scope: `metadata.go`, `load_metadata.go`, and their focused test owners. @@ -110,6 +110,17 @@ No public API or configuration contract changes are claimed by this stage. Validation: `go test . -run 'Metadata|Path|Provenance|Historical'`, then the everyday checker. Handoff should identify the helper and constructor signatures for Stage 2. +Completion record (2026-09-12): extracted `effectivePathIndex(final)` from final +metadata publication and introduced `newResolutionInput(metadata, final, sources)`. +The private view owns cloned metadata/source records, indexes source IDs, and exposes +private detached lookup helpers for the next step; it intentionally has no public API +or load-lifecycle wiring yet. `TestResolutionInputKeepsDetachedEntryMetadata` covers +root, defaults, explicit null, missing descendants, escaped keys, contextual parsed +sequence indices, key/index mismatches, detached records, and stability after real +recorded resolution. `go test . -run 'Metadata|Path|Provenance|Historical'` and +`./scripts/check.sh` passed with Go 1.26.7 on Linux. No deviations or remaining +Stage 1 risks were identified. + ## Stage 2 — Wire the callback API and enforce lifetime Status: not started. Dependencies: Stage 1. diff --git a/load_metadata.go b/load_metadata.go index 4135195..20e3081 100644 --- a/load_metadata.go +++ b/load_metadata.go @@ -6,6 +6,42 @@ import ( "gitea.maximumdirect.net/eric/ConfigKit/internal/document" ) +// resolutionInput is the immutable entry-state provenance available to a +// resolution callback. It owns detached metadata and source records so later +// resolution updates cannot change the entry view. +type resolutionInput struct { + metadata Metadata + sources map[string]SourceInfo +} + +func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo) resolutionInput { + frozen := cloneMetadata(metadata) + frozen.effectivePaths = effectivePathIndex(final) + indexed := make(map[string]SourceInfo, len(sources)) + for _, source := range sources { + if source.ID == "" { + panic("configkit: source identity is missing") + } + if _, exists := indexed[source.ID]; exists { + panic("configkit: source identity is duplicated") + } + indexed[source.ID] = cloneSourceInfo(source) + } + return resolutionInput{metadata: frozen, sources: indexed} +} + +func (input resolutionInput) lookup(path Path) (FieldInfo, bool) { + return input.metadata.Lookup(path) +} + +func (input resolutionInput) source(id string) (SourceInfo, bool) { + source, ok := input.sources[id] + if !ok { + return SourceInfo{}, false + } + return cloneSourceInfo(source), true +} + func metadataFromDecoded(decoded decodedConfiguration) Metadata { metadata := Metadata{ fields: make(map[string]FieldInfo), diff --git a/metadata.go b/metadata.go index 1d57c23..b4d3bce 100644 --- a/metadata.go +++ b/metadata.go @@ -113,9 +113,8 @@ func (m Metadata) hasHistoricalPath(path Path) bool { } func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) { - m.effectivePaths = make(map[string]Path) + m.effectivePaths = effectivePathIndex(final) m.historicalPaths = make(map[string][]Path) - walkDocument(final, Path{}, func(path Path, _ *document.Node) { m.effectivePaths[path.String()] = path.clone() }) add := func(path Path, _ *document.Node) { pointer := path.String() for _, prior := range m.historicalPaths[pointer] { @@ -138,3 +137,13 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his walkDocument(application.value, prefix, add) } } + +// effectivePathIndex records document-derived paths with their contextual segment +// kinds. Callers retain no aliases to the walked tree or its paths. +func effectivePathIndex(final *document.Node) map[string]Path { + paths := make(map[string]Path) + walkDocument(final, Path{}, func(path Path, _ *document.Node) { + paths[path.String()] = path.clone() + }) + return paths +} diff --git a/metadata_test.go b/metadata_test.go index fa80ecc..bdf425e 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -148,6 +148,89 @@ func TestMetadataQueriesReturnDetachedRecords(t *testing.T) { } } +func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) { + t.Parallel() + + type configuration struct { + Default string `yaml:"default"` + Items []string `yaml:"items"` + Values map[string]string `yaml:"values"` + Nullable *string `yaml:"nullable"` + } + schema := mustSchemaFor[configuration](t) + document := parseDocumentForDecode(t, "items: [first]\nvalues: {'0': key, 'a/b~': escaped}\nnullable: null\n") + decoded, issues := decodeConfiguration(schema, reflect.ValueOf(configuration{Default: "retained"}), document) + if len(issues) != 0 { + t.Fatalf("decode issues = %#v", issues) + } + metadata := metadataFromDecoded(decoded) + input := newResolutionInput(metadata, decoded.final, []SourceInfo{{ + ID: "source-1", Label: "decode.yml", Layer: LayerBase, + ImportedFrom: &Location{SourceID: "parent", Line: 1, Column: 1}, + }}) + + parsedItem, err := ParsePath("/items/0") + if err != nil { + t.Fatal(err) + } + parsedKey, err := ParsePath("/values/0") + if err != nil { + t.Fatal(err) + } + for _, path := range []Path{Path{}, Keys("default"), Keys("nullable"), parsedItem, parsedKey, Keys("values", "a/b~")} { + if _, ok := input.lookup(path); !ok { + t.Fatalf("entry path %s is missing", path) + } + } + defaultInfo, _ := input.lookup(Keys("default")) + nullInfo, _ := input.lookup(Keys("nullable")) + if defaultInfo.Explicit || !nullInfo.Explicit || !nullInfo.Null { + t.Fatalf("default = %#v, nullable = %#v", defaultInfo, nullInfo) + } + for _, path := range []Path{ + Keys("default", "child"), + Keys("items", "0"), + Keys("values").Index(0), + Keys("items").Index(1), + } { + if _, ok := input.lookup(path); ok { + t.Fatalf("entry lookup accepted %s", path) + } + } + + item, _ := input.lookup(parsedItem) + item.Origin.Locations[0].SourceID = "mutated" + source, ok := input.source("source-1") + if !ok { + t.Fatal("source is missing") + } + source.ImportedFrom.SourceID = "mutated" + + configurationAtEntry := decoded.value.Interface().(configuration) + resolved := runResolution(&configurationAtEntry, func(value *configuration, resolution *Resolution) error { + value.Items[0] = "normalized" + resolution.Record(Keys("items"), ChangeNormalization, "normalized item") + return nil + }, schema, decoded, document) + if len(resolved.diagnostics) != 0 { + t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics) + } + applyResolutionMetadata(&metadata, resolved) + metadata.effectivePaths = effectivePathIndex(resolved.final) + + again, ok := input.lookup(parsedItem) + if !ok || again.Origin.Locations[0].SourceID != "source-1" || again.Origin.Operation != "" { + t.Fatalf("entry metadata changed after resolution = %#v, %v", again, ok) + } + againSource, ok := input.source("source-1") + if !ok || againSource.ImportedFrom == nil || againSource.ImportedFrom.SourceID != "parent" { + t.Fatalf("entry source changed = %#v, %v", againSource, ok) + } + if _, ok := input.source(""); ok { + t.Fatal("empty source identity resolved") + } +} + func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) { result, err := Load[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}}) if err != nil {