From 28ef9b62003f0c26345dd967f108da0d1655d5e1 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 26 Oct 2026 14:30:25 +0000 Subject: [PATCH] Use entry metadata for resolution presence --- docs/roadmap/implementation.md | 6 ++- facade_resolution_presence_test.go | 63 ++++++++++++++++++++++++ internal/engine/resolution.go | 13 +++-- internal/engine/resolution_test.go | 79 ++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 facade_resolution_presence_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 72d986c..8e22c50 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Interface Contract Remediation Implementation Plan -Status: pending. All stages below remain unimplemented. +Status: in progress. Stage 1 is complete; Stages 2–18 remain pending. Baseline: `f6d54a094a2b5ada5748686620659399dce7ebf0`. Audience: a Luna coding agent executing the stages sequentially. @@ -290,7 +290,9 @@ change only defect expectations and tests tied to retired representations. Finding 1. Owners: `resolution.go`, `load_metadata.go`, resolution/provenance tests, and one root regression in `facade_compatibility_test.go` or a focused new facade -test file. Status: pending. +test file. Status: complete. Validation: +`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Resolution|Provenance|Default' -count=1` +and `GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'TestFacadeResolutionPresenceUsesDynamicEntryShape' -count=1`. 1. Add a private entry-presence helper using `resolutionInput.lookup` and its `FieldInfo.Explicit`. Use it in `Resolution.IsExplicit` after existing diff --git a/facade_resolution_presence_test.go b/facade_resolution_presence_test.go new file mode 100644 index 0000000..1063916 --- /dev/null +++ b/facade_resolution_presence_test.go @@ -0,0 +1,63 @@ +package configkit_test + +import ( + "errors" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestFacadeResolutionPresenceUsesDynamicEntryShape(t *testing.T) { + t.Parallel() + + type configuration struct { + Shape any `yaml:"shape"` + } + tests := []struct { + name string + input string + keyPresent bool + }{ + {name: "mapping key", input: "shape: {'0': input}\n", keyPresent: true}, + {name: "sequence index", input: "shape: [input]\n", keyPresent: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parsed, err := configkit.ParsePath("/shape/0") + if err != nil { + t.Fatal(err) + } + _, err = configkit.Load(configkit.Bytes("dynamic.yml", []byte(test.input)), configkit.Options[configuration]{ + Definition: configkit.Definition[configuration]{ + Resolve: func(_ *configuration, resolution *configkit.Resolution) error { + check := func(path configkit.Path, want bool) error { + info, found := resolution.InputInfo(path) + explicit := resolution.IsExplicit(path) + if found != want || explicit != want || (found && info.Explicit != explicit) { + return errors.New("resolution presence disagrees with dynamic entry shape") + } + return nil + } + for _, checkPath := range []struct { + path configkit.Path + want bool + }{ + {path: configkit.Keys("shape", "0"), want: test.keyPresent}, + {path: configkit.Keys("shape").Index(0), want: !test.keyPresent}, + {path: parsed, want: true}, + {path: configkit.Keys("shape", "missing"), want: false}, + } { + if err := check(checkPath.path, checkPath.want); err != nil { + return err + } + } + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/engine/resolution.go b/internal/engine/resolution.go index 1b56977..0d3818b 100644 --- a/internal/engine/resolution.go +++ b/internal/engine/resolution.go @@ -13,7 +13,6 @@ const closedCallbackContextMessage = "configkit: callback context is no longer a type resolutionState struct { active bool schema *schemaDescriptor - presence map[string]inputPresence document *document.Node input resolutionInput records map[string]*resolutionRecord @@ -78,8 +77,12 @@ func (r *Resolution) IsExplicit(path Path) bool { if !state.validPath(path) { return false } - _, explicit := state.presence[path.String()] - return explicit + return state.inputExplicit(path) +} + +func (state *resolutionState) inputExplicit(path Path) bool { + info, found := state.input.lookup(path) + return found && info.Explicit } // InputInfo returns detached entry-state metadata for path. The entry state is @@ -134,7 +137,7 @@ func Default[V any](resolution *Resolution, path Path, target *V, value V, reaso if !ok { return } - if _, explicit := state.presence[path.String()]; explicit { + if state.inputExplicit(path) { return } *target = value @@ -282,7 +285,7 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult { state := &resolutionState{ - active: true, schema: schema, presence: decoded.presence, document: input, + active: true, schema: schema, document: input, input: entry, records: make(map[string]*resolutionRecord), } diff --git a/internal/engine/resolution_test.go b/internal/engine/resolution_test.go index bb60576..a4d8e21 100644 --- a/internal/engine/resolution_test.go +++ b/internal/engine/resolution_test.go @@ -311,6 +311,85 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t } } +func TestResolutionPresenceUsesEntryShapeAcrossDynamicChanges(t *testing.T) { + t.Parallel() + + type config struct { + Dynamic any `yaml:"dynamic"` + } + tests := []struct { + name string + input string + keyPresent bool + }{ + {name: "mapping to sequence", input: "dynamic: {'0': input}\n", keyPresent: true}, + {name: "sequence to mapping", input: "dynamic: [input]\n", keyPresent: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parsed, err := ParsePath("/dynamic/0") + if err != nil { + t.Fatal(err) + } + var callbackErr error + result, err := testLoad(Bytes("dynamic.yml", []byte(test.input)), Options[config]{Definition: Definition[config]{ + Resolve: func(configuration *config, resolution *Resolution) error { + check := func(path Path, want bool) error { + info, found := resolution.InputInfo(path) + explicit := resolution.IsExplicit(path) + if found != want || explicit != want || (found && info.Explicit != explicit) { + return errors.New("resolution presence disagrees with entry shape") + } + return nil + } + keyPath := Keys("dynamic", "0") + indexPath := Keys("dynamic").Index(0) + for _, checkPath := range []struct { + path Path + want bool + }{ + {path: keyPath, want: test.keyPresent}, + {path: indexPath, want: !test.keyPresent}, + {path: parsed, want: true}, + {path: Keys("dynamic", "missing"), want: false}, + } { + if err := check(checkPath.path, checkPath.want); err != nil { + callbackErr = err + return err + } + } + + resolution.Record(Keys("dynamic"), ChangeNormalization, "change dynamic shape") + child := "" + if test.keyPresent { + configuration.Dynamic = []any{} + Default(resolution, indexPath, &child, "derived", "default new sequence child") + configuration.Dynamic = []any{child} + } else { + configuration.Dynamic = map[string]any{} + Default(resolution, keyPath, &child, "derived", "default new mapping child") + configuration.Dynamic = map[string]any{"0": child} + } + if child != "derived" { + callbackErr = errors.New("default was suppressed by the old container shape") + return callbackErr + } + return nil + }, + }}) + if callbackErr != nil { + t.Fatal(callbackErr) + } + if err != nil { + t.Fatal(err) + } + if len(result.resolution) != 2 || result.resolution[1].change.Origin.Operation != ChangeDerivedDefault { + t.Fatalf("resolution records = %#v", result.resolution) + } + }) + } +} + func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) { t.Parallel()