Apply CLI overrides after typed decoding

This commit is contained in:
2026-10-23 21:37:13 +00:00
parent ecfb78d84d
commit 41b5dff69b
20 changed files with 516 additions and 299 deletions

View File

@@ -431,7 +431,13 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
overrides: []Override{override},
}
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
input, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 0 {
t.Fatalf("assembly diagnostics = %#v", diagnostics)
}
_, diagnostics = preparePostStructuralOverrides(overridePreparationInput{
root: input.document, inputBytes: accounting.bytes, inputNodes: accounting.nodes,
}, input.sources, request.overrides, nil, limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
t.Fatalf("override diagnostics = %#v", diagnostics)
}

View File

@@ -76,13 +76,9 @@ func (r *Inspection) 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.
decodedInput := r.input
decodedOrigins := r.inputOrigins
if r.preCLI != nil {
decodedInput = r.preCLI
decodedOrigins = r.preCLIOrigins
}
// explicitly before typed CLI contributions and domain resolution.
decodedInput := r.preCLI
decodedOrigins := r.preCLIOrigins
replayed, _ := documentAtPath(working, path)
decoded, _ := documentAtPathWithLookup(decodedInput, path, r.immutableLookup)
if !configurationValuesEqual(replayed, decoded) {
@@ -97,41 +93,39 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
}
working = document.Clone(decodedInput)
if r.preCLI != nil {
for _, application := range r.postDecodeApps {
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
applyHistoryApplication(&working, application, replayIndex)
if !relevant {
continue
}
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: after,
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
})
for _, application := range r.postDecodeApps {
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
replayed, _ = documentAtPath(working, path)
effectiveInput, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
if !configurationValuesEqual(replayed, effectiveInput) {
origin := cloneOrigin(r.inputOrigins[path.String()])
origin.Operation = ChangeDecoding
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
Origin: origin,
Reasons: []string{"applied the typed CLI assignment"},
})
applyHistoryApplication(&working, application, replayIndex)
if !relevant {
continue
}
working = document.Clone(r.input)
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: after,
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
})
}
replayed, _ = documentAtPath(working, path)
effectiveInput, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
if !configurationValuesEqual(replayed, effectiveInput) {
origin := cloneOrigin(r.inputOrigins[path.String()])
origin.Operation = ChangeDecoding
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
Origin: origin,
Reasons: []string{"applied the typed CLI assignment"},
})
}
working = document.Clone(r.input)
for _, snapshot := range r.resolutionReplay {
relevant := pathsIntersect(snapshot.change.Path, path)
var before ValueSnapshot

View File

@@ -351,6 +351,10 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
opts.Overlay = "p"
}
result, err := testLoad(File(main), opts)
if cli && base == "replaced-text" {
requireSecretLoadCode(t, err, CodeDecodeType)
return
}
if err != nil {
t.Fatal(err)
}
@@ -360,6 +364,18 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
t.Fatal("missing explanation")
}
last := explanation.History[len(explanation.History)-1]
if cli && path.String() == "/m" {
if last.After != explanation.Effective || last.Origin.Layer != LayerCLI || last.Origin.Operation != "" {
t.Fatalf("CLI assignment was not the final history event: %#v", explanation)
}
continue
}
if cli && path.String() == "/m/a" {
if explanation.Effective.Present {
t.Fatalf("CLI map override retained descendants discarded by source null: %#v", explanation)
}
continue
}
if last.After != explanation.Effective || last.Origin.Operation != ChangeDecoding {
t.Fatalf("history does not reconcile final value: %#v", explanation)
}

View File

@@ -42,8 +42,8 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
// History applications borrow immutable policy-validated input trees. Metadata
// records answer presence and origin queries; replay reconstructs value changes.
// Replay reconciles with canonical decoding before domain resolution so both
// contracts agree on the effective result without retaining a tree per field.
// Replay reconciles with canonical decoding, applies supplied CLI contributions,
// then records domain resolution without retaining a tree per field.
type historyApplication struct {
layer Layer
path Path

View File

@@ -171,7 +171,7 @@ func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T
inspection := &Inspection{
metadata: metadata, baseline: &document.Node{Kind: document.Mapping},
input: composed.root, final: composed.root, applications: composed.applications,
input: composed.root, preCLI: composed.root, final: composed.root, applications: composed.applications,
}
inspection.secrets = classifySecretPaths(nil, func(candidate Path) bool {
return reflect.DeepEqual(candidate, path)
@@ -218,7 +218,7 @@ func TestReplacedProjectedSensitiveRecordRemainsRedacted(t *testing.T) {
finalizeMetadataPaths(&metadata, final, applications)
inspection := &Inspection{
metadata: metadata, baseline: &document.Node{Kind: document.Mapping},
input: final, final: final, applications: applications,
input: final, preCLI: final, final: final, applications: applications,
}
inspection.secrets = classifySecretPaths(nil, func(candidate Path) bool {
return reflect.DeepEqual(candidate, path)
@@ -290,7 +290,7 @@ func TestMappedUpsertProjectsSensitiveReplacementEvidence(t *testing.T) {
finalizeMetadataPaths(&metadata, finalRoot, applications)
inspection := &Inspection{
metadata: metadata, baseline: &document.Node{Kind: document.Mapping},
input: finalRoot, final: finalRoot, applications: applications,
input: finalRoot, preCLI: finalRoot, final: finalRoot, applications: applications,
}
secretRule := func(candidate Path) bool { return reflect.DeepEqual(candidate, secretPath) }
inspection.secrets = classifySecretPaths(nil, secretRule, metadata, finalRoot, applications)

View File

@@ -126,7 +126,7 @@ func inheritanceInspectionFromComposition(t *testing.T, composed *composedDocume
applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded)
finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root)
return &Inspection{
metadata: metadata, baseline: baseline, input: expanded.root,
metadata: metadata, baseline: baseline, input: expanded.root, preCLI: expanded.root,
inputOrigins: expanded.origins, final: expanded.root,
applications: composed.applications, inheritance: expanded.operations,
}, expanded

View File

@@ -176,7 +176,6 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
mainOptions.deferAssembly = true
mainOptions.SourceVersionMode = request.sourceVersionMode
mainOptions.Definition.Version = request.protectionVersion()
mainOptions.Overrides = request.overrides
if request.ordinary {
mainOptions.Overlay = request.overlay
mainOptions.directiveContext = rootDirectiveContextSource
@@ -278,6 +277,19 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
input.document = expanded.root
}
base := overridePreparationInput{root: input.document}
if input.composition != nil {
base.inputBytes = input.composition.inputBytes
base.inputNodes = input.composition.inputNodes
}
preparedOverrides, diagnostics := preparePostStructuralOverrides(base, input.sources, request.overrides, request.protectionVersion(), defaultImportLimits())
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
input.sources = preparedOverrides.sources
accumulator.sources = input.sources
decodeDefinition := request.definition
decodeDefinition.Version = request.protectionVersion()
decoded, diagnostics := decodeLoadInput(schema, decodeDefinition, input)
@@ -285,12 +297,25 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
if len(diagnostics) != 0 {
return nil, accumulator.loadError()
}
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, decoded)
decodeContext := decodeContext{}
if decodeDefinition.Version != nil {
decodeContext.versionPath = &decodeDefinition.Version.Path
}
assigned, issues := applyDecodedOverrides(schema, decoded, preparedOverrides, decodeContext)
if len(issues) != 0 {
for _, issue := range issues {
accumulator.add(diagnosticFromDecodeIssue(issue))
}
return nil, accumulator.loadError()
}
postDecode := preparePostDecodeOverrideContext(input, decoded, assigned)
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, postDecode)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots, input.inheritance) {
historyApplications := append(cloneHistoryApplications(resolved.applications), cloneHistoryApplications(resolved.postDecodeApps)...)
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, historyApplications, resolved.snapshots, input.inheritance) {
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError()
}
@@ -301,13 +326,13 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
accumulator.add(*ownershipIssue)
return nil, accumulator.loadError()
}
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, lookup)
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, resolved.referenceInput, lookup)
accumulator.add(secretResolution.diagnostics...)
if hasErrors(secretResolution.diagnostics) {
return nil, accumulator.loadError()
}
if request.definition.Validate != nil {
diagnostics = runValidation(&configuration, request.definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
diagnostics = runValidation(&configuration, request.definition.Validate, schema, resolved.final, resolved.referenceInput, secretResolution.lookups)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()

View File

@@ -287,7 +287,7 @@ func registerAcquiredSource(source Source, context *FilesystemContext, info *Sou
}
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
if !activeOverlay(options.Overlay) {
return input, nil
}
composition, diagnostics := compositionForInputWithPolicy(input, options.sequencePolicy)
@@ -309,21 +309,13 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
}
mergeResourceContexts(&input.contexts, contexts)
}
if len(options.Overrides) != 0 {
input.sources, diagnostics = assembly.applyOverrides(
input.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
)
if len(diagnostics) != 0 {
return input, diagnostics
}
}
input.composition = assembly.current
input.document = assembly.current.root
return input, nil
}
// compositionForInput initializes one handoff for overlays and CLI, including
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
// compositionForInput initializes the structural handoff used by inheritance
// expansion. Raw source bytes and mapping keys retain their budgets.
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
return compositionForInputWithPolicy(input, sequencePolicy{})
}

View File

@@ -167,7 +167,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
}
}
}
if assembly == nil && (activeOverlay(request.overlay) || len(request.overrides) != 0) {
if assembly == nil && activeOverlay(request.overlay) {
composition, _ := compositionForInput(assembledInput{})
assembly = newAssemblyAccumulator(composition)
}
@@ -193,13 +193,6 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
}
mergeResourceContexts(&input.contexts, contexts)
}
if len(request.overrides) != 0 {
var diagnostics []Diagnostic
input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.protectionVersion(), defaultImportLimits())
if len(diagnostics) != 0 {
return input, diagnostics
}
}
input.composition = assembly.current
input.document = assembly.current.root
return input, nil

View File

@@ -15,6 +15,11 @@ type resolvedLoad[T any] struct {
final *document.Node
snapshots []resolutionSnapshot
applications []historyApplication
postDecodeApps []historyApplication
structuralInput *document.Node
preCLI *document.Node
preCLIOrigins map[string]Origin
referenceInput *document.Node
versionEvidence []*sourceVersionEvidence
inheritance *inheritanceScope
contexts map[string]ResourceContext
@@ -46,24 +51,16 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
return decoded, nil
}
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, decoded decodedConfiguration) (resolvedLoad[T], []Diagnostic) {
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, prepared postDecodeOverrideContext) (resolvedLoad[T], []Diagnostic) {
var diagnostics []Diagnostic
decoded := prepared.decoded
configuration := decoded.value.Interface().(T)
metadata := metadataFromDecoded(decoded)
if input.inheritance != nil {
applyInheritanceMetadata(&metadata, input.composition.root, input.composition.origins, input.composition.overlays, input.inheritance)
} else if input.composition != nil {
applyOverlayMetadata(&metadata, input.composition.overlays)
}
metadata := prepared.metadata
final := decoded.final
referenceInput := prepared.referenceInput
var resolutionSnapshots []resolutionSnapshot
if definition.Resolve != nil {
sources := input.sources
if input.versionState != nil {
sources = sourceInfosWithVersionEvidence(sources, input.versionState.evidence, nil, true)
}
entry := newResolutionInput(metadata, decoded.final, sources, input.contexts)
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, referenceInput, prepared.resolution)
diagnostics = append(diagnostics, resolved.diagnostics...)
if hasErrors(resolved.diagnostics) {
return resolvedLoad[T]{}, diagnostics
@@ -71,6 +68,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
final = resolved.final
resolutionSnapshots = resolved.snapshots
applyResolutionMetadata(&metadata, resolved)
referenceInput = documentWithMetadataLocations(final, metadata, input.document)
}
applications := []historyApplication(nil)
if input.composition != nil {
@@ -82,7 +80,13 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
if input.versionState != nil {
versionEvidence = append(versionEvidence, input.versionState.evidence...)
}
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, versionEvidence: versionEvidence, inheritance: input.inheritance, contexts: input.contexts}, diagnostics
return resolvedLoad[T]{
configuration: configuration, decoded: decoded, metadata: metadata, final: final,
snapshots: resolutionSnapshots, applications: applications,
postDecodeApps: cloneHistoryApplications(prepared.postDecodeApps), structuralInput: document.Clone(prepared.structuralInput),
preCLI: document.Clone(prepared.preCLI), preCLIOrigins: cloneOrigins(prepared.preCLIOrigins), referenceInput: referenceInput,
versionEvidence: versionEvidence, inheritance: input.inheritance, contexts: input.contexts,
}, diagnostics
}
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
@@ -105,12 +109,15 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
}
decoded, metadata := resolved.decoded, resolved.metadata
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, applications, resolved.versionEvidence, decoded.baseline, decoded.final)
structuralInput := document.Clone(resolved.structuralInput)
preCLI := document.Clone(resolved.preCLI)
allApplications := append(cloneHistoryApplications(applications), cloneHistoryApplications(resolved.postDecodeApps)...)
finalizeMetadataPaths(&metadata, final, allApplications, decoded.baseline, resolved.structuralInput, resolved.preCLI, decoded.final)
secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, allApplications, resolved.versionEvidence, decoded.baseline, resolved.structuralInput, resolved.preCLI, decoded.final)
sources := sourceInfosWithVersionEvidence(accumulator.sources, resolved.versionEvidence, secrets, false)
retainedTrees := make([]*document.Node, 0, len(applications)+2)
retainedTrees = append(retainedTrees, decoded.final, final)
for _, application := range applications {
retainedTrees := make([]*document.Node, 0, len(allApplications)+5)
retainedTrees = append(retainedTrees, decoded.final, final, structuralInput, preCLI)
for _, application := range allApplications {
retainedTrees = append(retainedTrees, application.value)
}
inspection := &Inspection{
@@ -121,6 +128,10 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
baseline: decoded.baseline,
input: decoded.final,
inputOrigins: cloneOriginIndex(decoded.origins),
structuralInput: structuralInput,
preCLI: preCLI,
preCLIOrigins: cloneOrigins(resolved.preCLIOrigins),
postDecodeApps: cloneHistoryApplications(resolved.postDecodeApps),
final: final,
immutableLookup: buildDocumentLookup(retainedTrees...),
resolution: resolutionSnapshots,

View File

@@ -32,25 +32,13 @@ type overridePreparationInput struct {
inputNodes int
}
func overridePreparationInputFor(base *composedDocument) overridePreparationInput {
if base == nil {
return overridePreparationInput{}
}
return overridePreparationInput{root: base.root, inputBytes: base.inputBytes, inputNodes: base.inputNodes}
}
func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (preparedOverrideBatch, []Diagnostic) {
return prepareOverrideBatch(overridePreparationInputFor(base), sources, overrides, rule, limits, false)
}
// preparePostStructuralOverrides prepares the same detached batch for the
// lifecycle after structural expansion, where record inheritance directives
// are no longer valid CLI input.
// preparePostStructuralOverrides snapshots CLI input for typed assignment after
// structural expansion, when inheritance directives can no longer be supplied.
func preparePostStructuralOverrides(base overridePreparationInput, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (preparedOverrideBatch, []Diagnostic) {
return prepareOverrideBatch(base, sources, overrides, rule, limits, true)
return prepareOverrideBatch(base, sources, overrides, rule, limits)
}
func prepareOverrideBatch(base overridePreparationInput, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits, rejectInheritanceDirectives bool) (preparedOverrideBatch, []Diagnostic) {
func prepareOverrideBatch(base overridePreparationInput, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (preparedOverrideBatch, []Diagnostic) {
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
return preparedOverrideBatch{sources: cloneSourceInfos(sources)}, []Diagnostic{{
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
@@ -120,7 +108,7 @@ func prepareOverrideBatch(base overridePreparationInput, sources []SourceInfo, o
})
continue
}
if pathContainsDirective(override.Path) || (rejectInheritanceDirectives && pathContainsInheritanceDirective(override.Path)) {
if pathContainsDirective(override.Path) || pathContainsInheritanceDirective(override.Path) {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideDirective, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides cannot create configuration directives", Primary: location,
@@ -137,11 +125,7 @@ func prepareOverrideBatch(base overridePreparationInput, sources []SourceInfo, o
diagnostics = append(diagnostics, diagnostic)
continue
}
findDirective := overrideDirectivePath
if rejectInheritanceDirectives {
findDirective = overrideStructuralDirectivePath
}
if relative, ok := findDirective(value, Path{}); ok {
if relative, ok := overrideStructuralDirectivePath(value, Path{}); ok {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideDirective, Severity: SeverityError, Path: appendOverridePath(override.Path, relative),
Message: "configuration overrides cannot create configuration directives", Primary: location,
@@ -241,28 +225,27 @@ func appendOverridePath(base, relative Path) Path {
return result
}
func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
return overrideDirectivePathWithInheritance(node, path, false)
}
func overrideStructuralDirectivePath(node *document.Node, path Path) (Path, bool) {
return overrideDirectivePathWithInheritance(node, path, true)
return overrideStructuralDirectivePathAt(node, path)
}
func overrideDirectivePathWithInheritance(node *document.Node, path Path, rejectInheritanceDirectives bool) (Path, bool) {
func overrideStructuralDirectivePathAt(node *document.Node, path Path) (Path, bool) {
if node == nil {
return Path{}, false
}
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
child := path.Key(entry.Key)
if entry.Key == directiveKey || (rejectInheritanceDirectives && entry.Key == inheritanceKey) {
if entry.Key == directiveKey || entry.Key == inheritanceKey {
return child, true
}
if found, ok := overrideDirectivePathWithInheritance(entry.Value, child, rejectInheritanceDirectives); ok {
if found, ok := overrideStructuralDirectivePathAt(entry.Value, child); ok {
return found, true
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if found, ok := overrideDirectivePathWithInheritance(child, path.Index(index), rejectInheritanceDirectives); ok {
if found, ok := overrideStructuralDirectivePathAt(child, path.Index(index)); ok {
return found, true
}
}

View File

@@ -1,110 +0,0 @@
package engine
import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
if len(overrides) == 0 {
return base, cloneSourceInfos(sources), nil
}
assembly := newAssemblyAccumulator(cloneComposedDocument(base))
allSources, diagnostics := assembly.applyOverrides(sources, overrides, rule, limits)
if len(diagnostics) != 0 {
return nil, allSources, diagnostics
}
return assembly.current, allSources, nil
}
func (a *assemblyAccumulator) applyOverrides(sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
if len(overrides) == 0 {
return cloneSourceInfos(sources), nil
}
batch, diagnostics := prepareOverrides(a.current, sources, overrides, rule, limits)
if len(diagnostics) != 0 {
return batch.sources, diagnostics
}
for _, override := range batch.overrides {
applyPreparedOverride(a.current, override, &a.index)
a.current.inputBytes += override.bytes
a.current.inputNodes += override.nodes
}
return batch.sources, nil
}
func applyPreparedOverride(result *composedDocument, override preparedOverride, index *mappingIndex) {
location := document.Location{SourceID: override.source.ID}
mergeOverlayOrigin(result.origins, Path{}, location, LayerCLI)
current := result.root
currentPath := Path{}
for segmentIndex, segment := range override.path.segments {
path := currentPath.Key(segment.key)
position := index.find(current, segment.key)
if segmentIndex < len(override.path.segments)-1 {
if position < 0 {
child := &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{}}
index.append(current, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: child})
collectOverlayOrigins(result.origins, child, path, LayerCLI)
current = child
} else {
current = current.Mapping[position].Value
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
}
currentPath = path
continue
}
previous := cloneOrigin(result.origins[path.String()])
var displaced []displacedDefinition
if position < 0 {
incoming := document.Clone(override.value)
index.append(current, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: incoming})
collectOverlayOrigins(result.origins, incoming, path, LayerCLI)
} else {
existing := current.Mapping[position].Value
displaced = affectedOverrideOrigins(existing, override.value, result.origins, path, index)
if existing.Kind == document.Mapping && override.value.Kind == document.Mapping {
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
mergeOverlayMappings(result, existing, override.value, path, LayerCLI, false, nil, index)
} else {
deleteOriginSubtree(result.origins, path, existing)
current.Mapping[position] = document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: document.Clone(override.value)}
collectOverlayOrigins(result.origins, current.Mapping[position].Value, path, LayerCLI)
}
}
result.overlays = append(result.overlays, overlayRecord{change: Change{
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
}, displaced: displaced})
result.applications = append(result.applications, historyApplication{
layer: LayerCLI, path: path.clone(), value: override.value,
origin: overlayOrigin(LayerCLI, location),
})
}
}
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) []displacedDefinition {
var displaced []displacedDefinition
collectAffectedOverrideOrigins(&displaced, existing, incoming, origins, path, index)
return sortDisplacedDefinitions(displaced)
}
func collectAffectedOverrideOrigins(displaced *[]displacedDefinition, existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) {
if existing.Kind != document.Mapping || incoming.Kind != document.Mapping {
collectDisplacedOrigins(displaced, existing, origins, path, Origin{})
return
}
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origins[path.String()])})
for _, entry := range incoming.Mapping {
position := index.find(existing, entry.Key)
if position < 0 {
continue
}
childPath := path.Key(entry.Key)
childExisting := existing.Mapping[position].Value
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
collectAffectedOverrideOrigins(displaced, childExisting, entry.Value, origins, childPath, index)
} else {
collectDisplacedOrigins(displaced, childExisting, origins, childPath, Origin{})
}
}
}

View File

@@ -25,10 +25,16 @@ func preparePostDecodeOverrideContext(input assembledInput, before decodedConfig
after.final = document.Clone(assigned.final)
metadata := metadataFromDecoded(after)
cliRecords := overrideMetadataRecords(before, assigned.overrides)
var structuralOverlays []overlayRecord
if input.inheritance != nil && input.composition != nil {
applyInheritanceMetadata(&metadata, input.composition.root, input.composition.origins, input.composition.overlays, input.inheritance)
structuralOverlays = input.composition.overlays
applyInheritanceMetadata(&metadata, input.composition.root, input.composition.origins, append(cloneOverlayRecords(structuralOverlays), cliRecords...), input.inheritance)
} else if input.composition != nil {
applyOverlayMetadata(&metadata, input.composition.overlays)
structuralOverlays = input.composition.overlays
applyOverlayMetadata(&metadata, append(cloneOverlayRecords(structuralOverlays), cliRecords...))
} else if len(cliRecords) != 0 {
applyOverlayMetadata(&metadata, cliRecords)
}
sources := cloneSourceInfos(input.sources)
@@ -42,6 +48,9 @@ func preparePostDecodeOverrideContext(input assembledInput, before decodedConfig
knownSources[override.source.ID] = true
}
}
if input.versionState != nil {
sources = sourceInfosWithVersionEvidence(sources, input.versionState.evidence, nil, true)
}
resolution := newResolutionInput(metadata, assigned.final, sources, input.contexts)
applications := structuralHistoryApplications(input)
@@ -67,7 +76,54 @@ func preparePostDecodeOverrideContext(input assembledInput, before decodedConfig
sources: sources,
applications: applications,
postDecodeApps: postDecodeApps,
referenceInput: documentWithMetadataLocations(assigned.final, metadata),
referenceInput: documentWithMetadataLocations(assigned.final, metadata, input.document),
}
}
func overrideMetadataRecords(before decodedConfiguration, overrides []preparedOverride) []overlayRecord {
if len(overrides) == 0 {
return nil
}
lookup := buildDocumentLookup(before.final)
records := make([]overlayRecord, 0, len(overrides))
for _, override := range overrides {
location := document.Location{SourceID: override.source.ID}
previous := cloneOrigin(before.origins[override.path.String()])
var displaced []displacedDefinition
if existing, ok := documentAtPathWithLookup(before.final, override.path, lookup); ok {
displaced = affectedOverrideOrigins(existing, override.value, before.origins, override.path, new(mappingIndex))
}
records = append(records, overlayRecord{change: Change{
Path: override.path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
}, displaced: displaced})
}
return records
}
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) []displacedDefinition {
var displaced []displacedDefinition
collectAffectedOverrideOrigins(&displaced, existing, incoming, origins, path, index)
return sortDisplacedDefinitions(displaced)
}
func collectAffectedOverrideOrigins(displaced *[]displacedDefinition, existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) {
if existing.Kind != document.Mapping || incoming.Kind != document.Mapping {
collectDisplacedOrigins(displaced, existing, origins, path, Origin{})
return
}
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origins[path.String()])})
for _, entry := range incoming.Mapping {
position := index.find(existing, entry.Key)
if position < 0 {
continue
}
childPath := path.Key(entry.Key)
childExisting := existing.Mapping[position].Value
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
collectAffectedOverrideOrigins(displaced, childExisting, entry.Value, origins, childPath, index)
} else {
collectDisplacedOrigins(displaced, childExisting, origins, childPath, Origin{})
}
}
}
@@ -88,18 +144,26 @@ func structuralHistoryApplications(input assembledInput) []historyApplication {
// documentWithMetadataLocations attaches effective origin coordinates to a
// canonical typed projection for secret and validation diagnostics.
func documentWithMetadataLocations(root *document.Node, metadata Metadata) *document.Node {
func documentWithMetadataLocations(root *document.Node, metadata Metadata, fallback ...*document.Node) *document.Node {
located := document.Clone(root)
if located == nil {
return nil
}
var fallbackRoot *document.Node
if len(fallback) != 0 {
fallbackRoot = fallback[0]
}
var visit func(*document.Node, Path)
visit = func(node *document.Node, path Path) {
pointer := path.String()
if info, exists := metadata.fields[pointer]; exists {
if location, ok := metadataFieldLocation(metadata, pointer, info.Origin); ok {
node.Location = location
} else if location, ok := nearestDocumentLocation(fallbackRoot, path); ok {
node.Location = location
}
} else if location, ok := nearestDocumentLocation(fallbackRoot, path); ok {
node.Location = location
}
switch node.Kind {
case document.Mapping:
@@ -110,7 +174,13 @@ func documentWithMetadataLocations(root *document.Node, metadata Metadata) *docu
if location, ok := metadataFieldLocation(metadata, childPath.String(), info.Origin); ok {
entry.KeyLocation = location
entry.ValueLocation = location
} else if location, ok := nearestDocumentLocation(fallbackRoot, childPath); ok {
entry.KeyLocation = location
entry.ValueLocation = location
}
} else if location, ok := nearestDocumentLocation(fallbackRoot, childPath); ok {
entry.KeyLocation = location
entry.ValueLocation = location
}
visit(entry.Value, childPath)
}

View File

@@ -0,0 +1,207 @@
package engine
import (
"reflect"
"testing"
)
func TestPostDecodeOverrideLifecycleMatchesOrdinaryAndLayeredLoads(t *testing.T) {
t.Parallel()
type config struct {
Count int `yaml:"count"`
Derived int `yaml:"derived"`
}
input := Bytes("settings.yml", []byte("count: 4\n"))
defaultsCalls := 0
definition := Definition[config]{
Defaults: func() config {
defaultsCalls++
return config{Count: 9, Derived: 1}
},
Resolve: func(value *config, resolution *Resolution) error {
info, ok := resolution.InputInfo(Keys("count"))
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
t.Errorf("resolution count info = %#v, %v", info, ok)
}
if source, ok := resolution.Source("source-2"); !ok || source.Layer != LayerCLI {
t.Errorf("resolution CLI source = %#v, %v", source, ok)
}
Default(resolution, Keys("count"), &value.Count, 99, "preserve explicit count")
Default(resolution, Keys("derived"), &value.Derived, value.Count*2, "derive from CLI count", Keys("count"))
Normalize(resolution, Keys("count"), &value.Count, value.Count+1, "normalize after CLI")
return nil
},
}
options := Options[config]{Definition: definition, Overrides: []Override{{Path: Keys("count"), Value: 4, Flag: "--count"}}}
ordinary, err := testLoad(input, options)
if err != nil {
t.Fatal(err)
}
layered, err := testGroupedLoad(loadRequest[config]{
main: loadInput{source: input}, definition: definition,
overrides: []Override{{Path: Keys("count"), Value: 4, Flag: "--count"}},
})
if err != nil {
t.Fatal(err)
}
if defaultsCalls != 2 {
t.Fatalf("defaults factory calls = %d, want once per load", defaultsCalls)
}
for name, result := range map[string]*testResult[config]{"ordinary": ordinary, "layered": layered} {
if result.Config.Count != 5 || result.Config.Derived != 8 {
t.Errorf("%s resolved configuration = %#v", name, result.Config)
}
if got := historyLayers(result.Metadata().History(Keys("count"))); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerCLI, LayerResolution}) {
t.Errorf("%s count history layers = %#v", name, got)
}
info, ok := result.Metadata().Lookup(Keys("count"))
if !ok || info.Origin.Layer != LayerResolution {
t.Errorf("%s final count origin = %#v, %v", name, info, ok)
}
}
if !reflect.DeepEqual(ordinary.Config, layered.Config) ||
!reflect.DeepEqual(ordinary.Metadata().History(Keys("count")), layered.Metadata().History(Keys("count"))) {
t.Fatalf("ordinary and layered results differ: %#v / %#v", ordinary.Config, layered.Config)
}
}
func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
t.Parallel()
type config struct {
Count int `yaml:"count"`
Derived int `yaml:"derived"`
}
calls := 0
result, err := testLoad(NoSource(), Options[config]{
Definition: Definition[config]{
Defaults: func() config {
calls++
return config{Count: 2}
},
Resolve: func(value *config, resolution *Resolution) error {
info, ok := resolution.InputInfo(Keys("count"))
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
t.Errorf("resolution count info = %#v, %v", info, ok)
}
Default(resolution, Keys("count"), &value.Count, 10, "preserve CLI value")
Default(resolution, Keys("derived"), &value.Derived, value.Count*3, "derive from CLI count")
return nil
},
},
Overrides: []Override{{Path: Keys("count"), Value: 4, Flag: "--count"}},
})
if err != nil {
t.Fatal(err)
}
if calls != 1 || result.Config.Count != 4 || result.Config.Derived != 12 {
t.Fatalf("defaults-only CLI result = %#v, defaults calls %d", result.Config, calls)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].Layer != LayerCLI {
t.Fatalf("defaults-only CLI sources = %#v", sources)
}
}
func TestCLISequenceAssignmentReplacesAppendAndUpsertResults(t *testing.T) {
t.Parallel()
type item struct {
ID string `yaml:"id"`
}
type config struct {
Items []item `yaml:"items"`
}
declaration := func(mode string, id string) Source {
return Bytes(id, []byte("configkit:\n sequences:\n - destination: [items]\n key: id\n mode: "+mode+"\nitems: [{id: "+id+"}]\n"))
}
result, err := testGroupedLoad(loadRequest[config]{
main: loadInput{source: declaration("upsert", "main")},
underlays: []loadGroup{{inputs: []loadInput{{source: declaration("append", "underlay")}}}},
overrides: []Override{{Path: Keys("items"), Value: []any{map[string]any{"id": "cli"}}, Flag: "--items"}},
})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.Config.Items, []item{{ID: "cli"}}) {
t.Fatalf("CLI sequence assignment = %#v, want complete replacement", result.Config.Items)
}
}
func TestCLISecretReferenceUsesEffectiveLookupAndLocation(t *testing.T) {
t.Parallel()
type config struct {
APIKey Secret `yaml:"api_key"`
}
options := Options[config]{
Definition: Definition[config]{Defaults: func() config { return config{APIKey: SecretEnv("DEFAULT_SECRET")} }},
Overrides: []Override{{Path: Keys("api_key"), Value: "CLI_SECRET", Flag: "--api-key"}},
}
source := Bytes("secret-input", []byte("api_key: BASE_SECRET\n"))
t.Run("effective reference is looked up", func(t *testing.T) {
calls := 0
result, err := testLoadWithSecretLookup(source, func(name string) (string, bool) {
calls++
if name != "CLI_SECRET" {
t.Errorf("secret lookup name = %q", name)
}
return "effective-credential", true
}, options)
if err != nil || calls != 1 || result.Config.APIKey.Value() != "effective-credential" {
t.Fatalf("CLI secret result = %#v, calls %d, error %v", result, calls, err)
}
info, ok := result.Metadata().Lookup(Keys("api_key"))
if !ok || info.Origin.Layer != LayerCLI {
t.Fatalf("CLI secret origin = %#v, %v", info, ok)
}
})
t.Run("missing effective reference uses CLI location", func(t *testing.T) {
result, err := testLoadWithSecretLookup(source, func(name string) (string, bool) {
if name != "CLI_SECRET" {
t.Errorf("secret lookup name = %q", name)
}
return "", false
}, options)
if result != nil {
t.Fatalf("missing secret returned a result: %#v", result)
}
loadErr, ok := err.(*LoadError)
if !ok || len(loadErr.Diagnostics()) != 1 {
t.Fatalf("missing secret error = %v", err)
}
diagnostic := loadErr.Diagnostics()[0]
if diagnostic.Code != CodeSecretUnset || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
t.Fatalf("missing secret diagnostic = %#v", diagnostic)
}
})
}
func TestCLIOverridesCannotHideEarlierFailuresOrAddInheritance(t *testing.T) {
t.Parallel()
type config struct {
Count int `yaml:"count"`
Child map[string]any `yaml:"child"`
Parent map[string]any `yaml:"parent"`
}
t.Run("decode error", func(t *testing.T) {
_, err := testLoad(Bytes("bad-count", []byte("count: invalid\n")), Options[config]{
Overrides: []Override{{Path: Keys("count"), Value: 3, Flag: "--count"}},
})
requireSecretLoadCode(t, err, CodeDecodeType)
})
t.Run("inheritance error", func(t *testing.T) {
_, err := testLoad(Bytes("missing-parent", []byte("child: {extends: missing, value: source}\n")), Options[config]{
Overrides: []Override{{Path: Keys("child"), Value: map[string]any{"value": "cli"}, Flag: "--child"}},
})
requireSecretLoadCode(t, err, CodeInheritanceMissing)
})
t.Run("cannot add relationship", func(t *testing.T) {
_, err := testLoad(Bytes("new-relationship", []byte("child: {value: source}\nparent: {value: parent}\n")), Options[config]{
Overrides: []Override{{Path: Keys("child"), Value: map[string]any{"extends": "parent"}, Flag: "--child"}},
})
requireSecretLoadCode(t, err, CodeOverrideDirective)
})
t.Run("cannot cancel relationship", func(t *testing.T) {
_, err := testLoad(Bytes("cancel-relationship", []byte("child: {extends: parent}\nparent: {value: parent}\n")), Options[config]{
Overrides: []Override{{Path: Keys("child", "extends"), Value: nil, Flag: "--extends"}},
})
requireSecretLoadCode(t, err, CodeOverrideDirective)
})
}

View File

@@ -440,9 +440,11 @@ func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) {
t.Fatal(diagnostics)
}
base.inputNodes = 1
result, sources, diagnostics := applyOverrides(base, nil, []Override{{Path: Keys("value"), Value: "x", Flag: "--value"}}, nil, importLimits{sources: 1, bytes: 1, nodes: 2})
if result != nil || len(sources) != 1 || len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded || len(base.root.Mapping) != 0 {
t.Fatalf("bounded apply = %#v, %#v, %#v; base %#v", result, sources, diagnostics, base.root)
_, diagnostics = preparePostStructuralOverrides(overridePreparationInput{
root: base.root, inputBytes: base.inputBytes, inputNodes: base.inputNodes,
}, nil, []Override{{Path: Keys("value"), Value: "x", Flag: "--value"}}, nil, importLimits{sources: 1, bytes: 1, nodes: 2})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded || len(base.root.Mapping) != 0 {
t.Fatalf("bounded preparation = %#v; base %#v", diagnostics, base.root)
}
}
@@ -454,12 +456,14 @@ func TestPreparedOverrideBatchDetachesInputsAndAllocatesSourcesInOrder(t *testin
sources := []SourceInfo{{ID: "source-1", Label: "base", Layer: LayerBase, Kind: SourceKindMapping}}
firstValue := map[string]any{"nested": map[string]any{"name": "original"}}
secondValue := []any{map[string]any{"enabled": true}}
batch, diagnostics := prepareOverrides(base, sources, []Override{
batch, diagnostics := preparePostStructuralOverrides(overridePreparationInput{
root: base.root, inputBytes: base.inputBytes, inputNodes: base.inputNodes,
}, sources, []Override{
{Path: Keys("settings", "first"), Value: firstValue, Flag: "--first"},
{Path: Keys("settings", "second"), Value: secondValue, Flag: "--second"},
}, nil, defaultImportLimits())
if len(diagnostics) != 0 {
t.Fatalf("prepare overrides: %#v", diagnostics)
t.Fatalf("prepare post-structural overrides: %#v", diagnostics)
}
if len(batch.overrides) != 2 || len(batch.sources) != 3 {
t.Fatalf("prepared counts = %d overrides, %d sources", len(batch.overrides), len(batch.sources))
@@ -514,10 +518,9 @@ func TestPostStructuralOverridePreparationRejectsInheritanceDirectives(t *testin
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, diagnostics := prepareOverrides(base, nil, []Override{test.override}, nil, defaultImportLimits()); len(diagnostics) != 0 {
t.Fatalf("legacy preparation changed before lifecycle activation: %#v", diagnostics)
}
_, diagnostics := preparePostStructuralOverrides(overridePreparationInputFor(base), nil, []Override{test.override}, nil, defaultImportLimits())
_, diagnostics := preparePostStructuralOverrides(overridePreparationInput{
root: base.root, inputBytes: base.inputBytes, inputNodes: base.inputNodes,
}, nil, []Override{test.override}, nil, defaultImportLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideDirective || diagnostics[0].Path.String() != test.path {
t.Fatalf("post-structural diagnostics = %#v, want directive at %s", diagnostics, test.path)
}