diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b617336..2a57271 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–23 complete. +Status: implementation in progress; Stages 1–24 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -814,6 +814,11 @@ Validation: override, decode, schema, projection, and ownership tests plus the everyday check. Only the helper is added here; the shared lifecycle does not switch until its inspection/context support is prepared in Stage 25. +**Completion:** Added private schema-aware post-decode CLI assignment with +detached typed results, canonical projection, updated presence/origins, and +preserved pre-CLI/raw override snapshots. Verified focused override, decode, +schema, projection, and ownership tests plus `./scripts/check.sh`. + ## Stage 25 — Prepare Post-Decode CLI Inspection And Effective Context Scope: prepare history, resolution-input, and secret-lookup context handling; diff --git a/internal/engine/override_decode.go b/internal/engine/override_decode.go new file mode 100644 index 0000000..5cea947 --- /dev/null +++ b/internal/engine/override_decode.go @@ -0,0 +1,220 @@ +package engine + +import ( + "reflect" + "strings" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +// decodedOverrideResult keeps the typed pre-CLI projection and raw CLI +// contributions alongside the effective typed value and its inspection data. +type decodedOverrideResult struct { + value reflect.Value + preCLI *document.Node + final *document.Node + presence map[string]inputPresence + origins map[string]Origin + overrides []preparedOverride +} + +// applyDecodedOverrides assigns prepared CLI values to a detached, decoded +// configuration. Each sparse mapping is decoded against the current typed +// value, preserving omitted mapping fields while replacing supplied sequences. +func applyDecodedOverrides( + schema *schemaDescriptor, + decoded decodedConfiguration, + batch preparedOverrideBatch, + context decodeContext, +) (decodedOverrideResult, []decodeIssue) { + if schema == nil || schema.root == nil || !decoded.value.IsValid() || decoded.value.Type() != schema.root.typeOf || decoded.final == nil { + return decodedOverrideResult{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "decoded configuration does not match the schema"}} + } + + preCLI := document.Clone(decoded.final) + working, cloneIssue := cloneConfiguration(schema, decoded.value, defaultProjectionLimits()) + if cloneIssue != nil { + return decodedOverrideResult{}, []decodeIssue{decodeIssueFromProjection(cloneIssue)} + } + presence := cloneInputPresence(decoded.presence) + origins := cloneOrigins(decoded.origins) + currentProjection := document.Clone(decoded.final) + + for _, override := range batch.overrides { + if len(override.path.segments) == 0 { + return decodedOverrideResult{}, []decodeIssue{{code: CodeOverrideInvalidPath, message: "configuration override path is invalid", location: document.Location{SourceID: override.source.ID}}} + } + input, issue := decodedOverrideInput(override) + if issue != nil { + return decodedOverrideResult{}, []decodeIssue{*issue} + } + updated, issues := decodeValue(input, working, schema.root, Path{}, schema, context) + if len(issues) != 0 { + return decodedOverrideResult{}, issues + } + working = updated + + location := document.Location{SourceID: override.source.ID} + recordOverrideAncestors(presence, origins, override.path, location) + updateOverridePresence(presence, currentProjection, override.value, override.path, location) + updateOverrideOrigins(origins, currentProjection, override.value, override.path, location) + } + final, projectionIssue := projectConfiguration(schema, working, defaultProjectionLimits()) + if projectionIssue != nil { + return decodedOverrideResult{}, []decodeIssue{decodeIssueFromProjection(projectionIssue)} + } + + // New fields created by decoding a partial mapping into a previously nil + // pointer have no input origin. Fill those gaps as defaults while retaining + // all surviving source and CLI contributions already in the origin map. + fillDefaultOrigins(final, Path{}, origins) + + return decodedOverrideResult{ + value: working, + preCLI: preCLI, + final: final, + presence: presence, + origins: origins, + overrides: clonePreparedOverrides(batch.overrides), + }, nil +} + +func recordOverrideAncestors(presence map[string]inputPresence, origins map[string]Origin, path Path, location document.Location) { + mergeOverlayOrigin(origins, Path{}, location, LayerCLI) + prefix := Path{} + for index, segment := range path.segments { + prefix = prefix.Key(segment.key) + if index == len(path.segments)-1 { + break + } + pointer := prefix.String() + if _, exists := presence[pointer]; !exists { + presence[pointer] = inputPresence{location: location} + } + mergeOverlayOrigin(origins, prefix, location, LayerCLI) + } +} + +func decodedOverrideInput(override preparedOverride) (*document.Node, *decodeIssue) { + location := document.Location{SourceID: override.source.ID} + value := document.Clone(override.value) + if value == nil { + return nil, &decodeIssue{code: CodeOverrideInvalidPath, path: override.path.clone(), message: "configuration override value is invalid", location: location} + } + for index := len(override.path.segments) - 1; index >= 0; index-- { + segment := override.path.segments[index] + if segment.kind == pathSegmentIndex { + return nil, &decodeIssue{code: CodeOverrideInvalidPath, path: override.path.clone(), message: "configuration override path is invalid", location: location} + } + value = &document.Node{ + Kind: document.Mapping, + Location: location, + Mapping: []document.Entry{{ + Key: segment.key, KeyLocation: location, ValueLocation: location, Value: value, + }}, + } + } + return value, nil +} + +func cloneInputPresence(input map[string]inputPresence) map[string]inputPresence { + cloned := make(map[string]inputPresence, len(input)) + for path, value := range input { + cloned[path] = value + } + return cloned +} + +func cloneOrigins(input map[string]Origin) map[string]Origin { + cloned := make(map[string]Origin, len(input)) + for path, value := range input { + cloned[path] = cloneOrigin(value) + } + return cloned +} + +func clonePreparedOverrides(input []preparedOverride) []preparedOverride { + cloned := make([]preparedOverride, len(input)) + for index, value := range input { + cloned[index] = preparedOverride{ + path: value.path.clone(), + value: document.Clone(value.value), + source: value.source, + bytes: value.bytes, + nodes: value.nodes, + } + } + return cloned +} + +func updateOverridePresence(presence map[string]inputPresence, existing, incoming *document.Node, path Path, location document.Location) { + if existing != nil && existing.Kind == document.Mapping && incoming.Kind == document.Mapping { + if _, exists := presence[path.String()]; !exists { + presence[path.String()] = inputPresence{location: location} + } + var index mappingIndex + for _, entry := range incoming.Mapping { + childPath := path.Key(entry.Key) + position := index.find(existing, entry.Key) + var previous *document.Node + if position >= 0 { + previous = existing.Mapping[position].Value + } + updateOverridePresence(presence, previous, entry.Value, childPath, location) + } + return + } + + deletePresencePathSubtree(presence, path) + collectPresence(incoming, path, presence) +} + +func deletePresencePathSubtree(presence map[string]inputPresence, path Path) { + pointer := path.String() + for candidate := range presence { + if candidate == pointer || strings.HasPrefix(candidate, pointer+"/") { + delete(presence, candidate) + } + } +} + +func updateOverrideOrigins(origins map[string]Origin, existing, incoming *document.Node, path Path, location document.Location) { + if existing != nil && existing.Kind == document.Mapping && incoming.Kind == document.Mapping { + mergeOverlayOrigin(origins, path, location, LayerCLI) + var index mappingIndex + for _, entry := range incoming.Mapping { + childPath := path.Key(entry.Key) + position := index.find(existing, entry.Key) + var previous *document.Node + if position >= 0 { + previous = existing.Mapping[position].Value + } + updateOverrideOrigins(origins, previous, entry.Value, childPath, location) + } + return + } + + pointer := path.String() + for candidate := range origins { + if candidate == pointer || strings.HasPrefix(candidate, pointer+"/") { + delete(origins, candidate) + } + } + collectOverlayOrigins(origins, incoming, path, LayerCLI) +} + +func fillDefaultOrigins(node *document.Node, path Path, origins map[string]Origin) { + if _, exists := origins[path.String()]; !exists { + origins[path.String()] = Origin{Layer: LayerDefault} + } + switch node.Kind { + case document.Mapping: + for _, entry := range node.Mapping { + fillDefaultOrigins(entry.Value, path.Key(entry.Key), origins) + } + case document.Sequence: + for index, child := range node.Sequence { + fillDefaultOrigins(child, path.Index(index), origins) + } + } +} diff --git a/internal/engine/override_test.go b/internal/engine/override_test.go index 5494484..daf3cb3 100644 --- a/internal/engine/override_test.go +++ b/internal/engine/override_test.go @@ -547,3 +547,139 @@ func fmtLoadError(loadError *LoadError) string { _ = WriteDiagnostics(&output, loadError.Diagnostics()) return loadError.Error() + output.String() } + +func TestApplyDecodedOverridesUsesSchemaAndPreservesTypedOwnership(t *testing.T) { + t.Parallel() + type child struct { + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` + } + type config struct { + Nested child `yaml:"nested"` + Labels map[string]child `yaml:"labels"` + Items []child `yaml:"items"` + Empty []string `yaml:"empty"` + Count int `yaml:"count"` + Enabled bool `yaml:"enabled"` + Label string `yaml:"label"` + Small namedSmall `yaml:"small"` + Text validatedText `yaml:"text"` + Delay time.Duration `yaml:"delay"` + APIKey Secret `yaml:"api_key"` + Maybe *string `yaml:"maybe"` + } + defaultMaybe := "default" + defaults := config{ + Nested: child{Name: "default", Enabled: true}, + Labels: map[string]child{"default": {Name: "default", Enabled: true}}, + Items: []child{{Name: "default", Enabled: true}}, + Empty: []string{"default"}, + Count: 8, Enabled: true, Label: "default", Small: 7, Text: "default", Delay: time.Second, + Maybe: &defaultMaybe, + } + input := parseDocumentForDecode(t, "nested: {name: source, enabled: true}\nlabels: {old: {name: source, enabled: true}}\nitems: [{name: discarded, enabled: true}]\nempty: [source]\ncount: 7\nenabled: true\nlabel: source\nsmall: 3\ntext: source\ndelay: 1s\napi_key: SOURCE_SECRET\nmaybe: source\n") + schema, schemaIssue := buildSchema(reflect.TypeFor[config](), testSecretBinding()) + if schemaIssue != nil { + t.Fatalf("buildSchema() issue = %#v", schemaIssue) + } + decoded, decodeIssues := decodeConfiguration(schema, reflect.ValueOf(defaults), input) + if len(decodeIssues) != 0 { + t.Fatalf("decodeConfiguration() issues = %#v", decodeIssues) + } + batch, diagnostics := preparePostStructuralOverrides(overridePreparationInput{root: input}, nil, []Override{ + {Path: Keys("nested"), Value: map[string]any{"enabled": false}, Flag: "--nested"}, + {Path: Keys("labels"), Value: map[string]any{ + "old": map[string]any{"enabled": false}, "new": map[string]any{"name": "cli"}, + }, Flag: "--labels"}, + {Path: Keys("items"), Value: []any{map[string]any{"name": "replacement"}}, Flag: "--items"}, + {Path: Keys("empty"), Value: []any{}, Flag: "--empty"}, + {Path: Keys("count"), Value: 0, Flag: "--count"}, + {Path: Keys("enabled"), Value: false, Flag: "--enabled"}, + {Path: Keys("label"), Value: "", Flag: "--label"}, + {Path: Keys("small"), Value: 0, Flag: "--small"}, + {Path: Keys("text"), Value: "cli text", Flag: "--text"}, + {Path: Keys("delay"), Value: "250ms", Flag: "--delay"}, + {Path: Keys("api_key"), Value: "CLI_SECRET", Flag: "--api-key"}, + {Path: Keys("maybe"), Value: nil, Flag: "--maybe"}, + }, nil, defaultImportLimits()) + if len(diagnostics) != 0 { + t.Fatalf("preparePostStructuralOverrides() diagnostics = %#v", diagnostics) + } + result, issues := applyDecodedOverrides(schema, decoded, batch, decodeContext{}) + if len(issues) != 0 { + t.Fatalf("applyDecodedOverrides() issues = %#v", issues) + } + got := result.value.Interface().(config) + if got.Nested != (child{Name: "source", Enabled: false}) || !reflect.DeepEqual(got.Labels, map[string]child{ + "default": {Name: "default", Enabled: true}, + "old": {Name: "source", Enabled: false}, + "new": {Name: "cli"}, + }) || !reflect.DeepEqual(got.Items, []child{{Name: "replacement"}}) || got.Count != 0 || got.Enabled || + got.Empty == nil || len(got.Empty) != 0 || got.Label != "" || got.Small != 0 || + got.Text != "cli text" || got.Delay != 250*time.Millisecond || got.APIKey.name != "CLI_SECRET" || got.Maybe != nil { + t.Fatalf("post-decode configuration = %#v", got) + } + if result.preCLI == result.final || !reflect.DeepEqual(result.preCLI, decoded.final) || len(result.overrides) != len(batch.overrides) { + t.Fatalf("result snapshots/contributions = pre:%p final:%p overrides:%d", result.preCLI, result.final, len(result.overrides)) + } + if _, explicit := result.presence["/items/0/enabled"]; explicit { + t.Fatalf("replacement list inherited presence from its discarded item: %#v", result.presence) + } + if result.presence["/items/0/name"].location.SourceID != batch.overrides[2].source.ID || + result.presence["/nested"].location.SourceID != "source-1" || + result.origins["/items/0/name"].Layer != LayerCLI || result.origins["/nested/name"].Layer != LayerBase || + result.origins["/nested/enabled"].Layer != LayerCLI || result.origins["/labels/default/name"].Layer != LayerDefault { + t.Fatalf("presence/origins = %#v / %#v", result.presence, result.origins) + } + if node, ok := documentAtPath(result.final, Keys("items").Index(0).Key("enabled")); !ok || node.Kind != document.Boolean || node.Text != "false" { + t.Fatalf("replacement list default projection = %#v", node) + } + + got.Labels["new"] = child{Name: "mutated"} + result.overrides[0].value.Mapping[0].Value.Text = "changed" + if decoded.value.Interface().(config).Labels["old"].Name != "source" || batch.overrides[0].value.Mapping[0].Value.Text != "false" { + t.Fatal("post-decode result mutation reached the pre-CLI value or prepared batch") + } +} + +func TestApplyDecodedOverridesRejectsSchemaErrorsWithoutPartialResult(t *testing.T) { + t.Parallel() + type config struct { + Nested struct { + Kept int `yaml:"kept"` + } `yaml:"nested"` + Count int `yaml:"count"` + } + input := parseDocumentForDecode(t, "nested: {kept: 1}\ncount: 2\n") + schema := mustSchemaFor[config](t) + decoded, issues := decodeConfiguration(schema, reflect.ValueOf(config{}), input) + if len(issues) != 0 { + t.Fatalf("decodeConfiguration() issues = %#v", issues) + } + for _, test := range []struct { + name string + override Override + code Code + }{ + {name: "unknown nested field", override: Override{Path: Keys("nested"), Value: map[string]any{"unknown": 1}, Flag: "--nested"}, code: CodeDecodeUnknownField}, + {name: "invalid type", override: Override{Path: Keys("count"), Value: "not an integer", Flag: "--count"}, code: CodeDecodeType}, + } { + t.Run(test.name, func(t *testing.T) { + batch, diagnostics := preparePostStructuralOverrides(overridePreparationInput{root: input}, nil, []Override{test.override}, nil, defaultImportLimits()) + if len(diagnostics) != 0 { + t.Fatalf("preparePostStructuralOverrides() diagnostics = %#v", diagnostics) + } + result, issues := applyDecodedOverrides(schema, decoded, batch, decodeContext{}) + if len(issues) == 0 || issues[0].code != test.code { + t.Fatalf("decode issues = %#v, want code %q", issues, test.code) + } + if result.value.IsValid() || result.final != nil { + t.Fatalf("failed assignment returned a partial result: %#v", result) + } + original := decoded.value.Interface().(config) + if original.Count != 2 || original.Nested.Kept != 1 { + t.Fatal("failed assignment mutated the pre-CLI decoded value") + } + }) + } +}