Explain defaults restored during final decoding
This commit is contained in:
@@ -162,11 +162,17 @@ parser values. `Present=false` with empty YAML means absence or deletion; explic
|
|||||||
null is present and renders as `null`. The history starts with the canonical typed
|
null is present and renders as `null`. The history starts with the canonical typed
|
||||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||||
traversal order, the selected profile, supplied CLI records, and audited net
|
traversal order, the selected profile, supplied CLI records, and audited net
|
||||||
resolution operations. Explicit equal writes remain visible.
|
resolution operations. Explicit equal writes remain visible. When input replay
|
||||||
|
differs from the final typed input, a `ChangeDecoding` event records the transition
|
||||||
|
before domain resolution. Its origin retains the decoded path's source/default
|
||||||
|
attribution; its reason identifies static-default retention and canonical decoding.
|
||||||
|
This built-in inspection operation is not accepted by `Resolution.Record`.
|
||||||
|
|
||||||
Base, profile, and CLI history preserves the policy-validated input representation,
|
Base, profile, and CLI history preserves the policy-validated input representation,
|
||||||
including a superseded value that could not have decoded into the final Go field.
|
including a superseded value that could not have decoded into the final Go field.
|
||||||
Baseline, effective, and resolution values use canonical typed projection. Mapping
|
Baseline, effective, and resolution values use canonical typed projection. A
|
||||||
|
decoding event bridges replayed input and canonical typed values, so intermediate
|
||||||
|
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
|
||||||
queries reconstruct each source or override contribution, while sequences remain
|
queries reconstruct each source or override contribution, while sequences remain
|
||||||
atomic: a descendant history identifies its presence before and after the containing
|
atomic: a descendant history identifies its presence before and after the containing
|
||||||
sequence replacement without implying element-wise merging. Removed paths remain
|
sequence replacement without implying element-wise merging. Removed paths remain
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ Keep final projection, secret lookup, and validation ordering consistent.
|
|||||||
|
|
||||||
## 4. P2 — Reconcile history replay with final-only default application
|
## 4. P2 — Reconcile history replay with final-only default application
|
||||||
|
|
||||||
|
Status: complete. Explanation now records an explicit canonical decoding transition
|
||||||
|
when replay differs from final typed input, preserving decoded origins and showing
|
||||||
|
default restoration. Null/scalar-to-map profile and CLI regressions pass at parent
|
||||||
|
and descendant paths. Full tests pass with the issue-8 TMPDIR workaround; vet,
|
||||||
|
build and whitespace checks pass.
|
||||||
|
|
||||||
Evidence: [explain.go](../../explain.go), `Explain` initializes replay from defaults
|
Evidence: [explain.go](../../explain.go), `Explain` initializes replay from defaults
|
||||||
and merges each input into it. Actual decoding applies defaults to the final
|
and merges each input into it. Actual decoding applies defaults to the final
|
||||||
composed/overlaid input, so intermediate replacements are not lasting default resets.
|
composed/overlaid input, so intermediate replacements are not lasting default resets.
|
||||||
|
|||||||
20
explain.go
20
explain.go
@@ -117,6 +117,22 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intermediate input replacements do not permanently suppress defaults:
|
||||||
|
// decoding applies them to the final input tree. Show that reconciliation
|
||||||
|
// explicitly, including canonical scalar conversions, before domain changes.
|
||||||
|
replayed, _ := documentAtPath(working, path)
|
||||||
|
decoded, _ := documentAtPath(r.input, path)
|
||||||
|
if !documentNodesEqual(replayed, decoded) {
|
||||||
|
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||||
|
origin.Operation = ChangeDecoding
|
||||||
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
|
Before: valueSnapshot(working, path, r.secrets),
|
||||||
|
After: valueSnapshot(r.input, path, r.secrets),
|
||||||
|
Origin: origin,
|
||||||
|
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
working = document.Clone(r.input)
|
working = document.Clone(r.input)
|
||||||
for _, snapshot := range r.resolution {
|
for _, snapshot := range r.resolution {
|
||||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||||
@@ -422,7 +438,9 @@ func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
|||||||
rendered.WriteString("history:\n")
|
rendered.WriteString("history:\n")
|
||||||
for _, change := range explanation.History {
|
for _, change := range explanation.History {
|
||||||
rendered.WriteString(" - representation: ")
|
rendered.WriteString(" - representation: ")
|
||||||
if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
if change.Origin.Operation == ChangeDecoding {
|
||||||
|
rendered.WriteString("input to canonical typed\n")
|
||||||
|
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
||||||
rendered.WriteString("policy-validated input\n")
|
rendered.WriteString("policy-validated input\n")
|
||||||
} else {
|
} else {
|
||||||
rendered.WriteString("canonical typed\n")
|
rendered.WriteString("canonical typed\n")
|
||||||
|
|||||||
@@ -326,3 +326,42 @@ func historyValueLayers(explanation Explanation) []Layer {
|
|||||||
}
|
}
|
||||||
return layers
|
return layers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
||||||
|
type cfg struct {
|
||||||
|
M map[string]int `yaml:"m"`
|
||||||
|
}
|
||||||
|
for _, base := range []string{"null", "replaced-text"} {
|
||||||
|
for _, cli := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s/cli=%v", base, cli), func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
main := writeImportFile(t, root, "main.yml", "m: "+base+"\n")
|
||||||
|
opts := Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { return cfg{M: map[string]int{"a": 1}} }}}
|
||||||
|
if cli {
|
||||||
|
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
|
||||||
|
} else {
|
||||||
|
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
|
||||||
|
opts.Profile = "p"
|
||||||
|
}
|
||||||
|
result, err := Load(File(main), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("m"), Keys("m", "a")} {
|
||||||
|
explanation, ok := result.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
last := explanation.History[len(explanation.History)-1]
|
||||||
|
if last.After != explanation.Effective || last.Origin.Operation != ChangeDecoding {
|
||||||
|
t.Fatalf("history does not reconcile final value: %#v", explanation)
|
||||||
|
}
|
||||||
|
info, _ := result.Metadata().Lookup(path)
|
||||||
|
if last.Origin.Layer != info.Origin.Layer {
|
||||||
|
t.Fatal("decoding lost effective origin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2
load.go
2
load.go
@@ -72,6 +72,7 @@ type Result[T any] struct {
|
|||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
baseline *document.Node
|
baseline *document.Node
|
||||||
input *document.Node
|
input *document.Node
|
||||||
|
inputOrigins map[string]Origin
|
||||||
final *document.Node
|
final *document.Node
|
||||||
resolution []resolutionSnapshot
|
resolution []resolutionSnapshot
|
||||||
applications []historyApplication
|
applications []historyApplication
|
||||||
@@ -288,6 +289,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
|
|||||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||||
baseline: decoded.baseline,
|
baseline: decoded.baseline,
|
||||||
input: decoded.final,
|
input: decoded.final,
|
||||||
|
inputOrigins: cloneOriginIndex(decoded.origins),
|
||||||
final: final,
|
final: final,
|
||||||
resolution: resolutionSnapshots,
|
resolution: resolutionSnapshots,
|
||||||
applications: applications,
|
applications: applications,
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ const (
|
|||||||
LayerResolution Layer = "resolution"
|
LayerResolution Layer = "resolution"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChangeKind identifies an application resolution operation. The zero value is
|
// ChangeKind identifies a recorded operation. The zero value is
|
||||||
// used for input and default origins.
|
// used for input and default origins.
|
||||||
type ChangeKind string
|
type ChangeKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// ChangeDecoding records the built-in transition to canonical typed values.
|
||||||
|
// It is inspection metadata, not an operation accepted by Resolution.Record.
|
||||||
|
ChangeDecoding ChangeKind = "decoding"
|
||||||
ChangeDerivedDefault ChangeKind = "derived_default"
|
ChangeDerivedDefault ChangeKind = "derived_default"
|
||||||
ChangeNormalization ChangeKind = "normalization"
|
ChangeNormalization ChangeKind = "normalization"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user