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

@@ -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}
}
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 {
for _, entry := range node.Mapping {
childPath := current.Key(entry.Key)
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)
return []Diagnostic{{
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
}
}
} else if node.Kind == document.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
}
}

View File

@@ -271,7 +271,7 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
if activeOverlay(options.Overlay) {
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
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 {
return input, diagnostics

View File

@@ -146,7 +146,11 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
selection := Source{kind: sourceBytes}
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
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 {
return input, diagnostics
}

View File

@@ -52,8 +52,194 @@ func (a *assemblyAccumulator) applyOverlay(overlay *document.Node, incomingOrigi
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) {
mergeRecursiveMappings(target, overlay, current, index, recursiveMergeHooks{
mergeOverlayMappingsWithHooks(target, overlay, current, index, recursiveMergeHooks{
added: func(path Path, incoming *document.Node) {
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) {
collectOverlayOriginsFrom(origins, nil, node, path, layer)
}

View File

@@ -9,6 +9,7 @@ type recursiveMergeHooks struct {
added func(Path, *document.Node)
combined func(Path, *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
@@ -18,6 +19,14 @@ func mergeRecursiveMappings(target, incoming *document.Node, current Path, index
path := current.Key(entry.Key)
position := index.find(target, entry.Key)
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.Value = document.Clone(entry.Value)
index.append(target, cloned)
@@ -35,6 +44,13 @@ func mergeRecursiveMappings(target, incoming *document.Node, current Path, index
mergeRecursiveMappings(existing, entry.Value, path, index, hooks)
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 {
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)
}
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) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil {
@@ -50,7 +54,10 @@ func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContex
if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil {
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)
if diagnostic != nil {
@@ -62,14 +69,21 @@ func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContex
value.Primary = nil
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) {
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
defer func() {
if !closed {
@@ -131,17 +145,33 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
if base.current.inputNodes+nodeCount > limits.nodes {
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
}
overlayPayload := overlayRoot
if versionState != nil && versionState.mode != sourceVersionModeDefault {
processed, failures := versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping)
if len(failures) != 0 {
return sources, failures
var processed sourceVersionProcessingResult
var failures []Diagnostic
if len(policies) != 0 {
if versionState != nil {
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 {
location := publicLocation(versionNode.Location)
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)}
}
if diagnostics := base.applyOverlay(overlayPayload, nil, nil, LayerOverlay); len(diagnostics) != 0 {
return sources, diagnostics
var overlayDiagnostics []Diagnostic
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.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) {
directory := t.TempDir()
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()
mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: base}]\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"})
if err != nil {
t.Fatalf("replacement overlay error code = %s", loadCode(t, err))
}
if len(result.Config.Items) != 2 {
t.Fatalf("replacement overlay = %#v, %v", result, err)
_, err := testLoad[config](File(mainPath), Options[config]{Definition: definition, Overlay: "testing"})
if got := loadCode(t, err); got != CodeSequenceConflict {
t.Fatalf("replacement overlay error code = %s, want %s", got, CodeSequenceConflict)
}
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,
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()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: main-v}\nsettings: {kept: base}\n")
overlayPath := writeImportFile(t, directory, "overlays/testing.yml", "schema: {version: overlay-v}\nsettings: {changed: overlay}\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}
configkit:
sequences:
- destination: [items]
key: id
mode: append
settings: {changed: overlay}
items: [{id: overlay}]
`)
var checked []string
loaded, err := Load(File(mainPath), testSecretBinding(), Options[map[string]any]{
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" {
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()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning {
t.Fatalf("overlay warning was not retained: %#v", diagnostics)