Prepare resolution entry provenance view
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
13
metadata.go
13
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user