Apply CLI overrides to decoded configuration
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Sequence Identity And Combination Implementation Plan
|
# 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
|
Audience: a Luna coding agent implementing one numbered stage per prompt, in
|
||||||
order. This plan starts at commit
|
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
|
everyday check. Only the helper is added here; the shared lifecycle does not
|
||||||
switch until its inspection/context support is prepared in Stage 25.
|
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
|
## Stage 25 — Prepare Post-Decode CLI Inspection And Effective Context
|
||||||
|
|
||||||
Scope: prepare history, resolution-input, and secret-lookup context handling;
|
Scope: prepare history, resolution-input, and secret-lookup context handling;
|
||||||
|
|||||||
220
internal/engine/override_decode.go
Normal file
220
internal/engine/override_decode.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -547,3 +547,139 @@ func fmtLoadError(loadError *LoadError) string {
|
|||||||
_ = WriteDiagnostics(&output, loadError.Diagnostics())
|
_ = WriteDiagnostics(&output, loadError.Diagnostics())
|
||||||
return loadError.Error() + output.String()
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user