Activate selected overlay collection policies

This commit is contained in:
2026-10-23 19:30:48 +00:00
parent 9a7027a04c
commit 89a9684f0d
11 changed files with 708 additions and 44 deletions

View File

@@ -824,12 +824,13 @@ headers are consumed before strict composition and cannot supply a registered
record identity. Final typed decoding and application validation still run on the record identity. Final typed decoding and application validation still run on the
completed configuration. completed configuration.
Ordinary source documents may also declare collection behavior under the root Ordinary sources and selected overlays may declare collection behavior under
`configkit.sequences` key. Each entry has a required literal `destination` and may `configkit.sequences` at the document root. Each entry has a required literal
specify `key` as one string or a nonempty list of strings, plus a `mode` of `destination` and may specify `key` as one string or a nonempty list of strings,
`replace`, `append`, or `upsert`. These controls are removed before application plus a `mode` of `replace`, `append`, or `upsert`. These controls are removed
decoding. A declaration destination is relative to that source's placement, so a before application decoding. A declaration destination is relative to that
mounted fragment's declaration is mounted with its values. Declarations from source's placement, so a mounted fragment's declaration is mounted with its
values. Declarations from
strict peers must agree on identity fields and on any explicitly supplied mode; strict peers must agree on identity fields and on any explicitly supplied mode;
they apply even when the declaring source has no value at that destination. they apply even when the declaring source has no value at that destination.
Singleton sequence contributions are validated as well as peers. A key supplies Singleton sequence contributions are validated as well as peers. A key supplies
@@ -839,9 +840,11 @@ a keyed collection uses `upsert`, while an unkeyed collection uses `replace`.
replaces a complete matched record at its existing position and appends new replaces a complete matched record at its existing position and appends new
identities in incoming order; it does not merge record fields. identities in incoming order; it does not merge record fields.
At this boundary, declarations govern ordinary strict source composition, Declarations govern ordinary strict source composition, including loads with
including loads with imports disabled. They do not change selected-overlay or imports disabled, and selected-overlay precedence. Overlay declarations apply
layered precedence, inheritance, or CLI behavior. A root `configkit.imports` after the base source graph is composed and must agree with retained collection
identity and any application registration. They do not change layered
precedence, inheritance, or CLI behavior. A root `configkit.imports`
declaration still requires `Options.Imports`; `NoSource` has no declaration scope. declaration still requires `Options.Imports`; `NoSource` has no declaration scope.
An empty mapping contributes no child definitions. Two empty mappings compose, and An empty mapping contributes no child definitions. Two empty mappings compose, and
@@ -885,10 +888,15 @@ origin data; history retains the displaced definitions and identifies the parent
replacement. Surviving overlay paths have overlay origins, while recursively shared replacement. Surviving overlay paths have overlay origins, while recursively shared
mapping containers retain their contributing locations. mapping containers retain their contributing locations.
An overlay may not contain `configkit` anywhere, contribute the configured schema An overlay may contain only root `configkit.sequences` declarations; overlay
version, or replace an ancestor of that version with a scalar or null. These rules imports and nested directives are rejected. Sequence declarations are removed
apply even if the supplied version text would be unchanged. Overlays cannot mask a before application decoding. At declared or registered sequence destinations,
base parse, composition, or version failure because those checks run first. `replace`, `append`, and `upsert` use the same identity and policy rules as source
composition. Other overlay sequences remain atomic replacements. An overlay may
not contribute the configured schema version or replace an ancestor of that
version with a scalar or null. These rules apply even if the supplied version
text would be unchanged. Overlays cannot mask a base parse, composition, or
version failure because those checks run first.
## Explicit CLI overrides ## Explicit CLI overrides

View File

@@ -1,6 +1,6 @@
# Sequence Identity And Combination Implementation Plan # Sequence Identity And Combination Implementation Plan
Status: implementation in progress; Stages 1–13 complete. Status: implementation in progress; Stages 1–14 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
@@ -519,6 +519,10 @@ using the source/declaration handoff already active in Stage 13.
Validation: ordinary-load, overlay, composition, policy, and metadata tests plus Validation: ordinary-load, overlay, composition, policy, and metadata tests plus
the everyday check. the everyday check.
**Completion:** Implemented and verified with ordinary overlay-operation,
sequence-policy, source-version, and metadata tests. Validation commands:
`go test ./internal/engine -count=1` and `./scripts/check.sh`.
## Stage 15 — Integrate Lower Policy Across Layered Assembly ## Stage 15 — Integrate Lower Policy Across Layered Assembly
Scope: source-backed underlay coordination and the layered selected-overlay path, Scope: source-backed underlay coordination and the layered selected-overlay path,

View File

@@ -218,24 +218,28 @@ func nodeImportDiagnostic(code Code, path Path, message string, node *document.N
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location} return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
} }
func overlayDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic { func overlayDirectiveDiagnostics(node *document.Node, current Path, allowSequences ...bool) []Diagnostic {
sequencesAllowed := len(allowSequences) != 0 && allowSequences[0]
if node.Kind == document.Mapping { if node.Kind == document.Mapping {
for _, entry := range node.Mapping { for _, entry := range node.Mapping {
childPath := current.Key(entry.Key) childPath := current.Key(entry.Key)
if entry.Key == directiveKey { if entry.Key == directiveKey {
if sequencesAllowed && len(current.segments) == 0 && entry.Value != nil && entry.Value.Kind == document.Mapping && len(entry.Value.Mapping) == 1 && entry.Value.Mapping[0].Key == "sequences" {
continue
}
location := publicLocation(entry.KeyLocation) location := publicLocation(entry.KeyLocation)
return []Diagnostic{{ return []Diagnostic{{
Code: CodeOverlayDirective, Severity: SeverityError, Path: childPath, Code: CodeOverlayDirective, Severity: SeverityError, Path: childPath,
Message: "overlays cannot contain configuration directives", Primary: &location, Message: "overlays may contain only root collection sequence declarations", Primary: &location,
}} }}
} }
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath, sequencesAllowed); len(diagnostics) != 0 {
return diagnostics return diagnostics
} }
} }
} else if node.Kind == document.Sequence { } else if node.Kind == document.Sequence {
for index, child := range node.Sequence { for index, child := range node.Sequence {
if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 { if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index), sequencesAllowed); len(diagnostics) != 0 {
return diagnostics return diagnostics
} }
} }

View File

@@ -271,7 +271,7 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
if activeOverlay(options.Overlay) { if activeOverlay(options.Overlay) {
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion( input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
source, options.Filesystem, input.parsed, assembly, input.sources, source, options.Filesystem, input.parsed, assembly, input.sources,
options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState, options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState, options.sequencePolicy,
) )
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics

View File

@@ -146,7 +146,11 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
selection := Source{kind: sourceBytes} selection := Source{kind: sourceBytes}
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}} selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
var diagnostics []Diagnostic var diagnostics []Diagnostic
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState) var overlayPolicy []sequencePolicy
if request.ordinary {
overlayPolicy = []sequencePolicy{request.sequencePolicy}
}
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState, overlayPolicy...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }

View File

@@ -52,8 +52,194 @@ func (a *assemblyAccumulator) applyOverlay(overlay *document.Node, incomingOrigi
return nil return nil
} }
// applyOverlayWithCollectionPolicy is the shared recursive-precedence entry for
// overlays. The caller supplies only the policy and declarations owned by this
// contribution; layered loading can use the same operation when its lifecycle
// reaches this boundary.
func (a *assemblyAccumulator) applyOverlayWithCollectionPolicy(
overlay *document.Node,
incomingOrigins map[string]Origin,
applications []historyApplication,
layer Layer,
sequencePolicy sequencePolicy,
declarations []sequenceDeclarationSpec,
) []Diagnostic {
if a == nil || a.current == nil || a.current.root == nil || a.current.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
return invalidOverlayDocument()
}
mergeOriginContribution(a.current.origins, Path{}, incomingOrigin(incomingOrigins, Path{}, overlay, layer))
sequences, diagnostics := mergeOverlayMappingsWithCollectionPolicy(a, overlay, incomingOrigins, layer, sequencePolicy, declarations)
if len(diagnostics) != 0 {
return diagnostics
}
if len(applications) == 0 {
application := historyApplication{layer: layer, value: overlay, sequences: sequences}
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
a.current.applications = append(a.current.applications, application)
} else {
for _, application := range applications {
application.layer = layer
a.current.applications = append(a.current.applications, application)
}
}
return nil
}
func mergeOverlayMappingsWithCollectionPolicy(
assembly *assemblyAccumulator,
overlay *document.Node,
incomingOrigins map[string]Origin,
layer Layer,
sequencePolicy sequencePolicy,
declarations []sequenceDeclarationSpec,
) ([]sequenceHistoryContribution, []Diagnostic) {
result := assembly.current
claims := compositionClaims{byPath: make(map[string][]compositionClaim)}
claims.append(overlay, Path{}, 0)
identityFieldsByPath, resolvedPolicies, diagnostics := resolveStrictCollectionPolicies(
claims.ordered, sequencePolicy, result.collectionPolicies, declarations,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
registeredPaths := make(map[string]Path)
for _, declaration := range declarations {
key := declaration.destination.String()
declarationsByPath[key] = append(declarationsByPath[key], declaration)
registeredPaths[key] = declaration.destination.clone()
}
registered := make(map[string]bool)
for _, claim := range claims.ordered {
if claim.kind == document.Sequence {
if _, ok := sequencePolicy.identityFieldsAt(claim.path); ok {
registered[claim.path.String()] = true
registeredPaths[claim.path.String()] = claim.path.clone()
}
}
}
for key := range declarationsByPath {
registered[key] = true
}
for key := range result.collectionPolicies.collections {
if node, found := documentAtPath(overlay, result.collectionPolicies.collections[key].destination); found && node != nil {
registered[key] = true
registeredPaths[key] = result.collectionPolicies.collections[key].destination.clone()
}
}
resolutions := make(map[string]collectionPolicyResolution, len(registered))
for key := range registered {
path := registeredPaths[key]
policy, hasPolicy := resolvedPolicies.at(path)
if !hasPolicy {
continue
}
operation := sequenceModeReplace
if policy.explicitModePresent {
operation = policy.explicitMode
} else if len(identityFieldsByPath[key]) != 0 {
operation = sequenceModeUpsert
}
resolutions[key] = collectionPolicyResolution{
policy: policy, operation: operation,
effectiveIdentityFields: cloneStrings(identityFieldsByPath[key]),
}
}
sequenceApplications := make(map[string]sequenceHistoryContribution)
policyValues := make(map[string]bool)
var combinationDiagnostics []Diagnostic
mergeOverlayMappingsWithHooks(result.root, overlay, Path{}, &assembly.index,
recursiveMergeHooks{
added: func(path Path, incoming *document.Node) {
collectOverlayOriginsFrom(result.origins, incomingOrigins, incoming, path, layer)
},
combined: func(path Path, incoming *document.Node) {
mergeOriginContribution(result.origins, path, incomingOrigin(incomingOrigins, path, incoming, layer))
},
replaced: func(path Path, existing, incoming *document.Node) {
assembly.current.collectionPolicies.retireCollectionPoliciesAtOrBelow(path)
recordOverlayReplacement(result, path, existing, incoming, layer, true, incomingOrigins)
},
merge: func(path Path, existing, incoming *document.Node) (*document.Node, bool) {
resolution, active := resolutions[path.String()]
if !active {
return nil, false
}
combined, issues := combineSequence(existing, incoming, resolution, sequenceCombinationContext{destination: path})
if len(issues) != 0 {
combinationDiagnostics = append(combinationDiagnostics, issues...)
return existing, false
}
applyOverlaySequenceEvidence(result, path, existing, incoming, combined, resolution, incomingOrigins, layer)
policyValues[path.String()] = true
history := sequenceHistoryContribution{path: path.clone(), mode: sequenceHistoryMapped}
if resolution.operation == sequenceModeReplace {
history.mode = sequenceHistoryReplace
if incoming != nil && incoming.Kind == document.Sequence {
history.length = len(incoming.Sequence)
}
} else {
history.items = append([]sequenceItemProjection(nil), combined.higherItems...)
history.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
}
sequenceApplications[path.String()] = history
return combined.value, true
},
},
)
if len(combinationDiagnostics) != 0 {
return nil, canonicalDiagnostics(combinationDiagnostics)
}
for key, grouped := range declarationsByPath {
if policyValues[key] {
continue
}
path := grouped[0].destination
applicationIdentity, _ := sequencePolicy.identityFieldsAt(path)
current, diagnostics := resolveCollectionPolicy(path, grouped, applicationIdentity, nil)
if len(diagnostics) != 0 {
combinationDiagnostics = append(combinationDiagnostics, diagnostics...)
continue
}
lower, found := assembly.current.collectionPolicies.at(path)
var lowerPolicy *collectionPolicy
if found {
lowerPolicy = &lower
}
retained, _ := documentAtPath(assembly.current.root, path)
if retained != nil && retained.Kind == document.Sequence && len(current.effectiveIdentityFields) != 0 {
if _, issues := validateSequenceIdentity(retained, path, current.effectiveIdentityFields, sequenceIdentityDiagnosticCodes{invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict}); len(issues) != 0 {
combinationDiagnostics = append(combinationDiagnostics, issues...)
continue
}
}
policy, issues := collectionPolicyWithoutValue(lowerPolicy, current.policy, retained, path)
if len(issues) != 0 {
combinationDiagnostics = append(combinationDiagnostics, issues...)
continue
}
assembly.current.collectionPolicies.set(path, policy)
}
if len(combinationDiagnostics) != 0 {
return nil, canonicalDiagnostics(combinationDiagnostics)
}
keys := make([]string, 0, len(sequenceApplications))
for key := range sequenceApplications {
keys = append(keys, key)
}
slices.Sort(keys)
sequences := make([]sequenceHistoryContribution, 0, len(keys))
for _, key := range keys {
sequences = append(sequences, sequenceApplications[key])
}
return sequences, nil
}
func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path, layer Layer, recordReplacements bool, incomingOrigins map[string]Origin, index *mappingIndex) { func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path, layer Layer, recordReplacements bool, incomingOrigins map[string]Origin, index *mappingIndex) {
mergeRecursiveMappings(target, overlay, current, index, recursiveMergeHooks{ mergeOverlayMappingsWithHooks(target, overlay, current, index, recursiveMergeHooks{
added: func(path Path, incoming *document.Node) { added: func(path Path, incoming *document.Node) {
collectOverlayOriginsFrom(result.origins, incomingOrigins, incoming, path, layer) collectOverlayOriginsFrom(result.origins, incomingOrigins, incoming, path, layer)
}, },
@@ -78,6 +264,147 @@ func mergeOverlayMappings(result *composedDocument, target, overlay *document.No
}) })
} }
func mergeOverlayMappingsWithHooks(
target, overlay *document.Node,
current Path,
index *mappingIndex,
hooks recursiveMergeHooks,
) {
mergeRecursiveMappings(target, overlay, current, index, hooks)
}
func recordOverlayReplacement(
result *composedDocument,
path Path,
existing, incoming *document.Node,
layer Layer,
recordReplacement bool,
incomingOrigins map[string]Origin,
) {
var displaced []displacedDefinition
var previous Origin
if existing != nil {
displaced = displacedOrigins(existing, result.origins, path)
previous = cloneOrigin(result.origins[path.String()])
deleteOriginSubtree(result.origins, path, existing)
}
collectOverlayOriginsFrom(result.origins, incomingOrigins, incoming, path, layer)
if recordReplacement && existing != nil {
result.overlays = append(result.overlays, overlayRecord{
change: Change{
Path: path.clone(), Origin: incomingOrigin(incomingOrigins, path, incoming, layer),
Previous: previousOrigins(previous),
},
displaced: displaced,
})
}
}
func applyOverlaySequenceEvidence(
result *composedDocument,
path Path,
lower, higher *document.Node,
combined sequenceCombinationResult,
resolution collectionPolicyResolution,
incomingOrigins map[string]Origin,
layer Layer,
) {
lowerPolicy, hadLowerPolicy := result.collectionPolicies.at(path)
if resolution.operation == sequenceModeReplace {
var lowerValue *collectionPolicy
if hadLowerPolicy {
lowerValue = &lowerPolicy
}
result.collectionPolicies.retireCollectionPoliciesAtOrBelow(path)
resolution.policy = collectionPolicyForReplacement(lowerValue, resolution.policy)
}
if combined.value == nil || combined.value.Kind != document.Sequence {
recordOverlayReplacement(result, path, lower, higher, layer, true, incomingOrigins)
result.collectionPolicies.set(path, resolution.policy)
return
}
if resolution.operation == sequenceModeReplace {
var displaced []displacedDefinition
var previous Origin
if lower != nil {
displaced = displacedOrigins(lower, result.origins, path)
previous = cloneOrigin(result.origins[path.String()])
deleteOriginSubtree(result.origins, path, lower)
}
mergeOriginContribution(result.origins, path, incomingOrigin(incomingOrigins, path, higher, layer))
for _, projection := range combined.higherItems {
if higher != nil && higher.Kind == document.Sequence && projection.inputIndex >= 0 && projection.inputIndex < len(higher.Sequence) {
source := higher.Sequence[projection.inputIndex]
collectOverlayOriginsFrom(result.origins, incomingOrigins, source, path.Index(projection.outputIndex), layer)
}
}
if lower != nil {
result.overlays = append(result.overlays, overlayRecord{
change: Change{
Path: path.clone(), Origin: incomingOrigin(incomingOrigins, path, higher, layer),
Previous: previousOrigins(previous),
}, displaced: displaced,
})
}
} else {
mergeOriginContribution(result.origins, path, incomingOrigin(incomingOrigins, path, higher, layer))
for _, replacement := range combined.replacements {
lowerPath := path.Index(replacement.lowerIndex)
higherPath := path.Index(replacement.higherIndex)
outputPath := path.Index(replacement.outputIndex)
if lower == nil || lower.Kind != document.Sequence || replacement.lowerIndex < 0 || replacement.lowerIndex >= len(lower.Sequence) ||
higher == nil || higher.Kind != document.Sequence || replacement.higherIndex < 0 || replacement.higherIndex >= len(higher.Sequence) {
continue
}
lowerItem := lower.Sequence[replacement.lowerIndex]
higherItem := higher.Sequence[replacement.higherIndex]
displaced := displacedOrigins(lowerItem, result.origins, lowerPath)
previous := cloneOrigin(result.origins[lowerPath.String()])
deleteOriginSubtree(result.origins, lowerPath, lowerItem)
collectOverlayOriginsFrom(result.origins, incomingOrigins, higherItem, outputPath, layer)
result.overlays = append(result.overlays, overlayRecord{
change: Change{
Path: outputPath.clone(), Origin: incomingOrigin(incomingOrigins, higherPath, higherItem, layer),
Previous: previousOrigins(previous),
},
displaced: displaced,
})
}
for _, projection := range combined.higherItems {
if higher != nil && higher.Kind == document.Sequence && projection.inputIndex >= 0 && projection.inputIndex < len(higher.Sequence) {
higherItem := higher.Sequence[projection.inputIndex]
collectOverlayOriginsFrom(result.origins, incomingOrigins, higherItem, path.Index(projection.outputIndex), layer)
}
}
}
policy := cloneCollectionPolicy(resolution.policy)
policy.recordOrder = nil
policy.recordPolicies = nil
if hadLowerPolicy && resolution.operation != sequenceModeReplace {
lowerProjection := newSequenceItemProjectionIndex(combined.lowerItems)
higherProjection := newSequenceItemProjectionIndex(combined.higherItems)
for outputIndex := range combined.value.Sequence {
if _, replaced := higherProjection.inputForOutput(outputIndex); replaced {
policy.setRecordPolicyAt(outputIndex, replacedCollectionRecordPolicy())
continue
}
if lowerIndex, retained := lowerProjection.inputForOutput(outputIndex); retained {
if record, found := lowerPolicy.recordPolicyAt(lowerIndex); found {
policy.setRecordPolicyAt(outputIndex, retainedCollectionRecordPolicy(record))
}
}
}
} else {
for outputIndex := range combined.value.Sequence {
policy.setRecordPolicyAt(outputIndex, replacedCollectionRecordPolicy())
}
}
result.collectionPolicies.set(path, policy)
}
func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path, layer Layer) { func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path, layer Layer) {
collectOverlayOriginsFrom(origins, nil, node, path, layer) collectOverlayOriginsFrom(origins, nil, node, path, layer)
} }

View File

@@ -9,6 +9,7 @@ type recursiveMergeHooks struct {
added func(Path, *document.Node) added func(Path, *document.Node)
combined func(Path, *document.Node) combined func(Path, *document.Node)
replaced func(Path, *document.Node, *document.Node) replaced func(Path, *document.Node, *document.Node)
merge func(Path, *document.Node, *document.Node) (*document.Node, bool)
} }
// mergeRecursiveMappings changes an owned mapping and never aliases incoming // mergeRecursiveMappings changes an owned mapping and never aliases incoming
@@ -18,6 +19,14 @@ func mergeRecursiveMappings(target, incoming *document.Node, current Path, index
path := current.Key(entry.Key) path := current.Key(entry.Key)
position := index.find(target, entry.Key) position := index.find(target, entry.Key)
if position < 0 { if position < 0 {
if hooks.merge != nil {
if merged, handled := hooks.merge(path, nil, entry.Value); handled {
cloned := entry
cloned.Value = document.Clone(merged)
index.append(target, cloned)
continue
}
}
cloned := entry cloned := entry
cloned.Value = document.Clone(entry.Value) cloned.Value = document.Clone(entry.Value)
index.append(target, cloned) index.append(target, cloned)
@@ -35,6 +44,13 @@ func mergeRecursiveMappings(target, incoming *document.Node, current Path, index
mergeRecursiveMappings(existing, entry.Value, path, index, hooks) mergeRecursiveMappings(existing, entry.Value, path, index, hooks)
continue continue
} }
if hooks.merge != nil {
if merged, handled := hooks.merge(path, existing, entry.Value); handled {
target.Mapping[position] = entry
target.Mapping[position].Value = document.Clone(merged)
continue
}
}
if hooks.replaced != nil { if hooks.replaced != nil {
hooks.replaced(path, existing, entry.Value) hooks.replaced(path, existing, entry.Value)

View File

@@ -41,7 +41,11 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
return loadSelectedOverlayIntoWithVersion(source, context, main, base, sources, name, rule, limits, nil) return loadSelectedOverlayIntoWithVersion(source, context, main, base, sources, name, rule, limits, nil)
} }
func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) { func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState, policies ...sequencePolicy) ([]SourceInfo, []Diagnostic) {
var policy sequencePolicy
if len(policies) != 0 {
policy = policies[0]
}
if nativeContext(source, context) { if nativeContext(source, context) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context) access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil { if diagnostic != nil {
@@ -50,7 +54,10 @@ func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContex
if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil { if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil {
return cloneSourceInfos(sources), []Diagnostic{*diagnostic} return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
} }
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState) if len(policies) == 0 {
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
}
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy)
} }
rootPath, _, _, diagnostic := importFilesystem(source, context, main) rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil { if diagnostic != nil {
@@ -62,14 +69,21 @@ func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContex
value.Primary = nil value.Primary = nil
return cloneSourceInfos(sources), []Diagnostic{value} return cloneSourceInfos(sources), []Diagnostic{value}
} }
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState) if len(policies) == 0 {
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
}
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy)
} }
func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) { func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, nil) return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, nil)
} }
func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) { func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState, policies ...sequencePolicy) ([]SourceInfo, []Diagnostic) {
var policy sequencePolicy
if len(policies) != 0 {
policy = policies[0]
}
closed := false closed := false
defer func() { defer func() {
if !closed { if !closed {
@@ -131,17 +145,33 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
if base.current.inputNodes+nodeCount > limits.nodes { if base.current.inputNodes+nodeCount > limits.nodes {
return sources, []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the aggregate node limit", overlayLocation)} return sources, []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the aggregate node limit", overlayLocation)}
} }
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 { if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}, len(policies) != 0); len(diagnostics) != 0 {
return sources, diagnostics return sources, diagnostics
} }
overlayPayload := overlayRoot var processed sourceVersionProcessingResult
if versionState != nil && versionState.mode != sourceVersionModeDefault { var failures []Diagnostic
processed, failures := versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping) if len(policies) != 0 {
if len(failures) != 0 { if versionState != nil {
return sources, failures processed, failures = versionState.processWithDirectiveContext(
overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping, rootDirectiveContextOverlay,
)
} else {
processed = processSourceVersionWithDirectiveContext(
overlayRoot, rule, sourceVersionContribution, Path{}, sourceVersionModeDefault, rootDirectiveContextOverlay,
)
failures = processed.diagnostics
}
} else {
if versionState != nil && versionState.mode != sourceVersionModeDefault {
processed, failures = versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping)
} else {
processed = sourceVersionProcessingResult{payload: overlayRoot}
} }
overlayPayload = processed.payload
} }
if len(failures) != 0 {
return sources, failures
}
overlayPayload := processed.payload
if versionNode := fragmentVersionClaim(overlayPayload, rule); versionNode != nil { if versionNode := fragmentVersionClaim(overlayPayload, rule); versionNode != nil {
location := publicLocation(versionNode.Location) location := publicLocation(versionNode.Location)
return sources, []Diagnostic{{ return sources, []Diagnostic{{
@@ -155,8 +185,14 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)} return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
} }
if diagnostics := base.applyOverlay(overlayPayload, nil, nil, LayerOverlay); len(diagnostics) != 0 { var overlayDiagnostics []Diagnostic
return sources, diagnostics if len(policies) != 0 {
overlayDiagnostics = base.applyOverlayWithCollectionPolicy(overlayPayload, nil, nil, LayerOverlay, policy, processed.sequences)
} else {
overlayDiagnostics = base.applyOverlay(overlayPayload, nil, nil, LayerOverlay)
}
if len(overlayDiagnostics) != 0 {
return sources, overlayDiagnostics
} }
base.current.inputBytes += int64(len(data)) base.current.inputBytes += int64(len(data))
base.current.inputNodes += nodeCount base.current.inputNodes += nodeCount

View File

@@ -94,6 +94,263 @@ value: scalar
} }
} }
func TestLoadSelectedOverlayAppliesCollectionOperationsAndEvidence(t *testing.T) {
t.Parallel()
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
Children []item `yaml:"children"`
}
type config struct {
Items []item `yaml:"items"`
}
tests := []struct {
name string
mode string
overlay string
want []item
}{
{
name: "append",
mode: "append",
overlay: `configkit:
sequences:
- destination: [items]
key: id
mode: append
items: [{id: c, value: added}]
`,
want: []item{{ID: "a", Value: "old"}, {ID: "b", Value: "kept"}, {ID: "c", Value: "added"}},
},
{
name: "upsert replaces complete records at retained positions",
mode: "upsert",
overlay: `configkit:
sequences:
- destination: [items]
key: id
mode: upsert
items: [{id: a, value: new}]
`,
want: []item{{ID: "a", Value: "new"}, {ID: "b", Value: "kept"}},
},
{
name: "replace",
mode: "replace",
overlay: `configkit:
sequences:
- destination: [items]
key: id
mode: replace
items: [{id: c, value: replacement}]
`,
want: []item{{ID: "c", Value: "replacement"}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: a, value: old, children: [{id: child}]}, {id: b, value: kept}]\n")
writeImportFile(t, directory, "overlays/testing.yml", test.overlay)
result, err := testLoad[config](File(mainPath), Options[config]{Overlay: "testing"})
if err != nil {
t.Fatal(err)
}
if len(result.Config.Items) != len(test.want) {
t.Fatalf("items = %#v, want %#v", result.Config.Items, test.want)
}
for index, want := range test.want {
if result.Config.Items[index].ID != want.ID || result.Config.Items[index].Value != want.Value {
t.Fatalf("items[%d] = %#v, want id %q value %q", index, result.Config.Items[index], want.ID, want.Value)
}
}
if _, found := result.Metadata().Lookup(Keys("configkit")); found {
t.Fatal("overlay collection controls reached application metadata")
}
if test.mode == "append" {
base, _ := result.Metadata().Lookup(Keys("items").Index(0).Key("value"))
added, _ := result.Metadata().Lookup(Keys("items").Index(2).Key("value"))
if base.Origin.Layer != LayerBase || added.Origin.Layer != LayerOverlay {
t.Fatalf("append origins = base %#v added %#v", base.Origin, added.Origin)
}
history := result.Metadata().History(Keys("items").Index(2).Key("value"))
if len(history) == 0 || history[len(history)-1].Origin.Layer != LayerOverlay {
t.Fatalf("append history = %#v", history)
}
}
if test.mode == "upsert" {
if _, found := result.Metadata().Lookup(Keys("items").Index(0).Key("children").Index(0)); found {
t.Fatal("upsert retained a discarded child")
}
history := result.Metadata().History(Keys("items").Index(0).Key("children").Index(0))
if len(history) < 2 {
t.Fatalf("discarded child history = %#v", history)
}
}
})
}
}
func TestLoadSelectedOverlayReconcilesPendingAndRetainedCollectionPolicy(t *testing.T) {
t.Parallel()
requireCode := func(t *testing.T, main, overlay string, code Code) {
t.Helper()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", main)
writeImportFile(t, directory, "overlays/testing.yml", overlay)
_, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Overlay: "testing"})
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != code {
t.Fatalf("load error = %#v, want %s", err, code)
}
}
t.Run("key only validates retained records", func(t *testing.T) {
requireCode(t, "items: [{id: duplicate}, {id: duplicate}]\n", `configkit:
sequences:
- destination: [items]
key: id
`, CodeSequenceConflict)
})
t.Run("mode only retains lower identity", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
sequences:
- destination: [items]
key: id
mode: append
items: [{id: base}]
`)
writeImportFile(t, directory, "overlays/testing.yml", `configkit:
sequences:
- destination: [items]
mode: append
items: [{id: overlay}]
`)
result, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Overlay: "testing"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.Config.Items, []ordinarySequenceItem{{ID: "base"}, {ID: "overlay"}}) {
t.Fatalf("items = %#v", result.Config.Items)
}
})
t.Run("lower explicit mode remains active when overlay omits it", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
sequences:
- destination: [items]
key: id
mode: append
items: [{id: base}]
`)
writeImportFile(t, directory, "overlays/testing.yml", "items: [{id: overlay}]\n")
result, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Overlay: "testing"})
if err != nil || !reflect.DeepEqual(result.Config.Items, []ordinarySequenceItem{{ID: "base"}, {ID: "overlay"}}) {
t.Fatalf("retained operation = %#v, %v", result, err)
}
})
t.Run("overlay identity must agree with retained identity", func(t *testing.T) {
requireCode(t, `configkit:
sequences:
- destination: [items]
key: id
items: [{id: base}]
`, `configkit:
sequences:
- destination: [items]
key: slug
items: [{id: overlay}]
`, CodeSequenceConflict)
})
t.Run("replace accepts null and append rejects it", func(t *testing.T) {
for _, test := range []struct {
mode string
want Code
}{
{mode: "replace"},
{mode: "append", want: CodeSequenceInvalid},
} {
overlay := "configkit:\n sequences:\n - destination: [items]\n key: id\n mode: " + test.mode + "\nitems: null\n"
if test.want != "" {
requireCode(t, "items: [{id: base}]\n", overlay, test.want)
continue
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: base}]\n")
writeImportFile(t, directory, "overlays/testing.yml", overlay)
result, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Overlay: "testing"})
if err != nil || result.Config.Items != nil {
t.Fatalf("replace null = %#v, %v", result, err)
}
}
})
t.Run("empty contributions and kind mismatches", func(t *testing.T) {
for _, test := range []struct {
name, mode, value string
want int
wantCode Code
}{
{name: "append empty", mode: "append", value: "[]", want: 1},
{name: "upsert empty", mode: "upsert", value: "[]", want: 1},
{name: "replace empty", mode: "replace", value: "[]", want: 0},
{name: "append mapping", mode: "append", value: "{id: overlay}", wantCode: CodeSequenceInvalid},
} {
t.Run(test.name, func(t *testing.T) {
overlay := "configkit:\n sequences:\n - destination: [items]\n key: id\n mode: " + test.mode + "\nitems: " + test.value + "\n"
if test.wantCode != "" {
requireCode(t, "items: [{id: base}]\n", overlay, test.wantCode)
return
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: base}]\n")
writeImportFile(t, directory, "overlays/testing.yml", overlay)
result, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Overlay: "testing"})
if err != nil || len(result.Config.Items) != test.want {
t.Fatalf("%s result = %#v, %v", test.name, result, err)
}
})
}
})
}
func TestLoadSelectedOverlayKeepsCollectionPoliciesIndependent(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
sequences:
- destination: [items]
key: id
mode: append
- destination: [catalog, items]
key: id
mode: append
items: [{id: base}]
catalog: {items: [{id: catalog-base}]}
`)
writeImportFile(t, directory, "overlays/testing.yml", `configkit:
sequences:
- destination: [items]
mode: upsert
- destination: [catalog, items]
mode: append
items: [{id: base}]
catalog: {items: [{id: catalog-overlay}]}
`)
result, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Overlay: "testing"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.Config.Items, []ordinarySequenceItem{{ID: "base"}}) ||
!reflect.DeepEqual(result.Config.Catalog.Items, []ordinarySequenceItem{{ID: "catalog-base"}, {ID: "catalog-overlay"}}) {
t.Fatalf("independent overlay policies = items %#v, catalog %#v", result.Config.Items, result.Config.Catalog.Items)
}
}
func TestLoadOverlaySelectionUsesOneFixedYAMLFile(t *testing.T) { func TestLoadOverlaySelectionUsesOneFixedYAMLFile(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n") mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n")

View File

@@ -394,19 +394,16 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
} }
}) })
t.Run("replacement overlay and CLI may introduce duplicates", func(t *testing.T) { t.Run("replacement overlay validates identities and CLI may introduce duplicates", func(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: base}]\n") mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: base}]\n")
writeImportFile(t, directory, "overlays/testing.yml", "items: [{id: duplicate}, {id: duplicate}]\n") writeImportFile(t, directory, "overlays/testing.yml", "items: [{id: duplicate}, {id: duplicate}]\n")
result, err := testLoad[config](File(mainPath), Options[config]{Definition: definition, Overlay: "testing"}) _, err := testLoad[config](File(mainPath), Options[config]{Definition: definition, Overlay: "testing"})
if err != nil { if got := loadCode(t, err); got != CodeSequenceConflict {
t.Fatalf("replacement overlay error code = %s", loadCode(t, err)) t.Fatalf("replacement overlay error code = %s, want %s", got, CodeSequenceConflict)
}
if len(result.Config.Items) != 2 {
t.Fatalf("replacement overlay = %#v, %v", result, err)
} }
result, err = testLoad[config](Bytes("main", []byte("items: [{id: base}]\n")), Options[config]{ result, err := testLoad[config](Bytes("main", []byte("items: [{id: base}]\n")), Options[config]{
Definition: definition, Definition: definition,
Overrides: []Override{{Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "duplicate"}, map[string]any{"id": "duplicate"}}}}, Overrides: []Override{{Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "duplicate"}, map[string]any{"id": "duplicate"}}}},
}) })

View File

@@ -1195,8 +1195,16 @@ func TestSourceVersionSelectedOverlayConsumesHeaderAndRetainsWarning(t *testing.
t.Parallel() t.Parallel()
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: main-v}\nsettings: {kept: base}\n") mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: main-v}\nsettings: {kept: base}\nitems: [{id: base}]\n")
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", "schema: {version: overlay-v}\nsettings: {changed: overlay}\n") overlayPath := writeImportFile(t, directory, "overlays/testing.yml", `schema: {version: overlay-v}
configkit:
sequences:
- destination: [items]
key: id
mode: append
settings: {changed: overlay}
items: [{id: overlay}]
`)
var checked []string var checked []string
loaded, err := Load(File(mainPath), testSecretBinding(), Options[map[string]any]{ loaded, err := Load(File(mainPath), testSecretBinding(), Options[map[string]any]{
Overlay: "testing", SourceVersionMode: sourceVersionModeRequired, Overlay: "testing", SourceVersionMode: sourceVersionModeRequired,
@@ -1220,6 +1228,9 @@ func TestSourceVersionSelectedOverlayConsumesHeaderAndRetainsWarning(t *testing.
if loaded.Config["schema"].(map[string]any)["version"] != "main-v" || loaded.Config["settings"].(map[string]any)["changed"] != "overlay" { if loaded.Config["schema"].(map[string]any)["version"] != "main-v" || loaded.Config["settings"].(map[string]any)["changed"] != "overlay" {
t.Fatalf("overlay payload = %#v", loaded.Config) t.Fatalf("overlay payload = %#v", loaded.Config)
} }
if _, found := loaded.Config["configkit"]; found || len(loaded.Config["items"].([]any)) != 2 {
t.Fatalf("overlay declarations or collection values = %#v", loaded.Config)
}
diagnostics := loaded.Inspection.Diagnostics() diagnostics := loaded.Inspection.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning { if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning {
t.Fatalf("overlay warning was not retained: %#v", diagnostics) t.Fatalf("overlay warning was not retained: %#v", diagnostics)