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
|
## 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.
|
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
|
Validation: `go test . -run 'Metadata|Path|Provenance|Historical'`, then the everyday
|
||||||
checker. Handoff should identify the helper and constructor signatures for Stage 2.
|
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
|
## Stage 2 — Wire the callback API and enforce lifetime
|
||||||
|
|
||||||
Status: not started. Dependencies: Stage 1.
|
Status: not started. Dependencies: Stage 1.
|
||||||
|
|||||||
@@ -6,6 +6,42 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"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 {
|
func metadataFromDecoded(decoded decodedConfiguration) Metadata {
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
fields: make(map[string]FieldInfo),
|
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) {
|
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)
|
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) {
|
add := func(path Path, _ *document.Node) {
|
||||||
pointer := path.String()
|
pointer := path.String()
|
||||||
for _, prior := range m.historicalPaths[pointer] {
|
for _, prior := range m.historicalPaths[pointer] {
|
||||||
@@ -138,3 +137,13 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
|
|||||||
walkDocument(application.value, prefix, add)
|
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) {
|
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"}}})
|
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 {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user