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
|
||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||
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,
|
||||
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
|
||||
atomic: a descendant history identifies its presence before and after the containing
|
||||
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
|
||||
|
||||
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
|
||||
and merges each input into it. Actual decoding applies defaults to the final
|
||||
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)
|
||||
for _, snapshot := range r.resolution {
|
||||
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")
|
||||
for _, change := range explanation.History {
|
||||
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")
|
||||
} else {
|
||||
rendered.WriteString("canonical typed\n")
|
||||
|
||||
@@ -326,3 +326,42 @@ func historyValueLayers(explanation Explanation) []Layer {
|
||||
}
|
||||
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
|
||||
baseline *document.Node
|
||||
input *document.Node
|
||||
inputOrigins map[string]Origin
|
||||
final *document.Node
|
||||
resolution []resolutionSnapshot
|
||||
applications []historyApplication
|
||||
@@ -288,6 +289,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
baseline: decoded.baseline,
|
||||
input: decoded.final,
|
||||
inputOrigins: cloneOriginIndex(decoded.origins),
|
||||
final: final,
|
||||
resolution: resolutionSnapshots,
|
||||
applications: applications,
|
||||
|
||||
@@ -11,11 +11,14 @@ const (
|
||||
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.
|
||||
type ChangeKind string
|
||||
|
||||
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"
|
||||
ChangeNormalization ChangeKind = "normalization"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user