Validate metadata queries against semantic path shapes

This commit is contained in:
2026-09-06 19:11:30 -05:00
parent edd82e15b2
commit be2ba9a20b
7 changed files with 158 additions and 9 deletions

View File

@@ -87,7 +87,10 @@ successful result. Callback panics remain programming failures.
### Layered metadata ### Layered metadata
`Result.Metadata` describes every final canonical path. Exact surviving input paths `Result.Metadata` describes every final canonical path. Queries retain explicit
key/index distinctions using actual effective and historical container shapes;
only parsed numeric tokens are resolved contextually. A descendant that never
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
are explicit and have a base or profile origin; omitted paths have a default origin. are explicit and have a base or profile origin; omitted paths have a default origin.
An explicit parent mapping does not make omitted children explicit. Null state and An explicit parent mapping does not make omitted children explicit. Null state and
available source coordinates are retained. `History` records the Go-zero or application available source coordinates are retained. `History` records the Go-zero or application

View File

@@ -157,6 +157,12 @@ agree while preserving reasons, dependencies, origins and redaction.
## 6. P2 — Preserve semantic path kinds and reject nonexistent explanation paths ## 6. P2 — Preserve semantic path kinds and reject nonexistent explanation paths
Status: complete. Metadata retains value-free effective/historical path shapes;
queries validate explicit segment kinds, and explanation omits absent-to-absent
operations for paths that never existed. Regressions cover wrong-kind and parsed
numeric queries, out-of-range indices, no-output failures and removed descendants.
Full tests pass with the issue-8 TMPDIR workaround; vet, build and whitespace pass.
Evidence: [metadata.go](../../metadata.go) indexes queries solely by `Path.String`, Evidence: [metadata.go](../../metadata.go) indexes queries solely by `Path.String`,
losing explicit key/index distinctions. [explain.go](../../explain.go), losing explicit key/index distinctions. [explain.go](../../explain.go),
`applicationOperationPath`, considers an atomic ancestor relevant even when the `applicationOperationPath`, considers an atomic ancestor relevant even when the

View File

@@ -86,7 +86,7 @@ func cloneSecretReferences(values map[string]string) map[string]string {
// Explain returns safe effective and chronological values for a known final or // Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false. // historical path. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) { func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil || r.final == nil || r.baseline == nil || !path.valid() { if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
return Explanation{}, false return Explanation{}, false
} }
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)} explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
@@ -110,9 +110,13 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if !relevant { if !relevant {
continue continue
} }
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {
continue
}
explanation.History = append(explanation.History, ValueChange{ explanation.History = append(explanation.History, ValueChange{
Before: before, Before: before,
After: valueSnapshot(working, path, r.secrets), After: after,
Origin: historyApplicationOrigin(application, operationPath), Origin: historyApplicationOrigin(application, operationPath),
}) })
} }
@@ -144,9 +148,13 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if !relevant { if !relevant {
continue continue
} }
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {
continue
}
explanation.History = append(explanation.History, ValueChange{ explanation.History = append(explanation.History, ValueChange{
Before: before, Before: before,
After: valueSnapshot(working, path, r.secrets), After: after,
Origin: cloneOrigin(snapshot.change.Origin), Origin: cloneOrigin(snapshot.change.Origin),
Reasons: append([]string(nil), snapshot.change.Reasons...), Reasons: append([]string(nil), snapshot.change.Reasons...),
Dependencies: clonePaths(snapshot.change.Dependencies), Dependencies: clonePaths(snapshot.change.Dependencies),

View File

@@ -281,6 +281,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
configuration = published.Interface().(T) configuration = published.Interface().(T)
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
return &Result[T]{ return &Result[T]{
Config: configuration, Config: configuration,

View File

@@ -184,6 +184,14 @@ func cloneMetadata(metadata Metadata) Metadata {
fields: make(map[string]FieldInfo, len(metadata.fields)), fields: make(map[string]FieldInfo, len(metadata.fields)),
history: make(map[string][]Change, len(metadata.history)), history: make(map[string][]Change, len(metadata.history)),
} }
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
for pointer, path := range metadata.effectivePaths {
cloned.effectivePaths[pointer] = path.clone()
}
cloned.historicalPaths = make(map[string][]Path, len(metadata.historicalPaths))
for pointer, paths := range metadata.historicalPaths {
cloned.historicalPaths[pointer] = clonePaths(paths)
}
for path, info := range metadata.fields { for path, info := range metadata.fields {
cloned.fields[path] = cloneFieldInfo(info) cloned.fields[path] = cloneFieldInfo(info)
} }

View File

@@ -1,16 +1,20 @@
package configkit package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// Metadata provides detached effective presence/origin and chronological history // Metadata provides detached effective presence/origin and chronological history
// queries for a load. Its zero value contains no paths. // queries for a load. Its zero value contains no paths.
type Metadata struct { type Metadata struct {
fields map[string]FieldInfo effectivePaths map[string]Path
history map[string][]Change historicalPaths map[string][]Path
fields map[string]FieldInfo
history map[string][]Change
} }
// Lookup returns information for an effective path. Unknown and invalid paths // Lookup returns information for an effective path. Unknown and invalid paths
// return false. // return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) { func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() { if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
return FieldInfo{}, false return FieldInfo{}, false
} }
info, ok := m.fields[path.String()] info, ok := m.fields[path.String()]
@@ -32,7 +36,7 @@ func (m Metadata) IsExplicit(path Path) bool {
// Removed historical paths can have history even though Lookup returns false. // Removed historical paths can have history even though Lookup returns false.
// Unknown and invalid paths return nil. // Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change { func (m Metadata) History(path Path) []Change {
if !path.valid() { if !m.hasHistoricalPath(path) {
return nil return nil
} }
history, ok := m.history[path.String()] history, ok := m.history[path.String()]
@@ -45,3 +49,75 @@ func (m Metadata) History(path Path) []Change {
} }
return cloned return cloned
} }
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
// the query. Candidate paths are constructed from actual document containers.
func matchesPathShape(query, candidate Path) bool {
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
return false
}
for i, segment := range query.segments {
actual := candidate.segments[i]
if actual.kind == pathSegmentIndex {
index, ok := schemaIndex(segment)
if !ok || index != actual.index {
return false
}
} else if segment.kind == pathSegmentIndex {
return false
}
}
return true
}
func (m Metadata) hasHistoricalPath(path Path) bool {
if !path.valid() {
return false
}
for _, candidate := range m.historicalPaths[path.String()] {
if matchesPathShape(path, candidate) {
return true
}
}
return false
}
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
m.effectivePaths = make(map[string]Path)
m.historicalPaths = make(map[string][]Path)
walkDocumentPaths(final, Path{}, func(path Path) { m.effectivePaths[path.String()] = path.clone() })
add := func(path Path) {
pointer := path.String()
for _, prior := range m.historicalPaths[pointer] {
if matchesPathShape(path, prior) {
return
}
}
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
}
walkDocumentPaths(final, Path{}, add)
for _, tree := range earlier {
walkDocumentPaths(tree, Path{}, add)
}
for _, application := range applications {
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
prefix := Path{}
for _, segment := range application.path.segments {
prefix = prefix.Key(segment.key)
}
walkDocumentPaths(application.value, prefix, add)
}
}
func walkDocumentPaths(node *document.Node, path Path, visit func(Path)) {
if node == nil {
return
}
visit(path)
for _, entry := range node.Mapping {
walkDocumentPaths(entry.Value, path.Key(entry.Key), visit)
}
for i, child := range node.Sequence {
walkDocumentPaths(child, path.Index(i), visit)
}
}

View File

@@ -1,12 +1,17 @@
package configkit package configkit
import "testing" import (
"bytes"
"testing"
)
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) { func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Parallel() t.Parallel()
path := Keys("service", "workers") path := Keys("service", "workers")
metadata := Metadata{ metadata := Metadata{
effectivePaths: map[string]Path{path.String(): path},
historicalPaths: map[string][]Path{path.String(): {path}},
fields: map[string]FieldInfo{ fields: map[string]FieldInfo{
path.String(): { path.String(): {
Explicit: true, Explicit: true,
@@ -56,3 +61,45 @@ func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Fatal("invalid path produced metadata") t.Fatal("invalid path produced metadata")
} }
} }
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 {
t.Fatal(err)
}
for _, path := range []Path{Keys("items", "0"), Keys("m").Index(0), Keys("x", "missing"), Keys("items").Index(8)} {
if _, ok := result.Metadata().Lookup(path); ok {
t.Fatalf("invalid lookup %s", path)
}
if result.Metadata().History(path) != nil {
t.Fatalf("invalid history %s", path)
}
if _, ok := result.Explain(path); ok {
t.Fatalf("invalid explanation %s", path)
}
var out bytes.Buffer
if err := result.WriteExplanation(&out, path); err == nil || out.Len() != 0 {
t.Fatalf("invalid path wrote output: %s", path)
}
}
for _, pointer := range []string{"/items/0", "/m/0"} {
path, _ := ParsePath(pointer)
if _, ok := result.Metadata().Lookup(path); !ok {
t.Fatal("parsed path lost lookup")
}
if _, ok := result.Explain(path); !ok {
t.Fatal("parsed path lost explanation")
}
}
retired := Keys("retired", "child")
if _, ok := result.Metadata().Lookup(retired); ok {
t.Fatal("removed path remains effective")
}
if len(result.Metadata().History(retired)) == 0 {
t.Fatal("removed history missing")
}
x, ok := result.Explain(retired)
if !ok || x.Effective.Present || len(x.History) == 0 {
t.Fatal("removed explanation missing")
}
}