Apply collection policies during sibling inheritance

This commit is contained in:
2026-10-23 20:19:05 +00:00
parent 9e75496664
commit 36ff9170b7
8 changed files with 704 additions and 15 deletions

View File

@@ -248,8 +248,14 @@ between tiers before resolving relationships. An omitted `extends` retains a
lower-priority relationship; `extends: null` cancels it while preserving the lower-priority relationship; `extends: null` cancels it while preserving the
child's own settings. Superseded references are historical evidence, not active child's own settings. Superseded references are historical evidence, not active
relationships to validate. Each child recursively overlays its own settings relationships to validate. Each child recursively overlays its own settings
over its resolved parent: explicit zero, false, empty string, sequence, and over its resolved parent: explicit zero, false, empty string, and
null values replace inherited values, while mappings combine recursively. null values replace inherited values, while mappings combine recursively.
Sequences at collection destinations use the shared collection operation with
the parent as lower input and the child as higher input. The destination's
explicit mode applies; otherwise a copied identity selects upsert and an
unkeyed destination replaces. A parent's explicit mode does not move to a
different destination. Sequences outside collection destinations remain atomic
replacements.
The child retains its key. All effective relationships resolve, including The child retains its key. All effective relationships resolve, including
entries the application may never select for execution. Partial parent entries the application may never select for execution. Partial parent
definitions undergo normal typed decoding and application-owned validation, definitions undergo normal typed decoding and application-owned validation,
@@ -853,7 +859,11 @@ main apply their resolved sequence operation between precedence tiers; a higher
strict scope inherits the lower identity and explicit mode before peer strict scope inherits the lower identity and explicit mode before peer
composition. Overlay declarations apply after the base source graph is composed composition. Overlay declarations apply after the base source graph is composed
and must agree with retained collection identity and any application and must agree with retained collection identity and any application
registration. These policies do not change inheritance or CLI behavior. A root registration. During mapping-sibling inheritance, a parent's YAML identity can
follow its collection to the child destination, while the parent's explicit
operation stays at the parent destination. A child declaration selects the
operation at its destination; absent one, keyed collections use upsert and
unkeyed collections use replace. These policies do not change CLI behavior. A root
`configkit.imports` declaration still requires `Options.Imports`; `NoSource` has `configkit.imports` declaration still requires `Options.Imports`; `NoSource` has
no declaration scope. no declaration scope.

View File

@@ -1,6 +1,6 @@
# Sequence Identity And Combination Implementation Plan # Sequence Identity And Combination Implementation Plan
Status: implementation in progress; Stages 1–17 complete. Status: implementation in progress; Stages 1–18 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
@@ -641,6 +641,12 @@ handoff; existing mapping-sibling relationship selection remains unchanged.
Validation: inheritance, history, provenance, sensitivity, and resource-context Validation: inheritance, history, provenance, sensitivity, and resource-context
tests plus the everyday check. No identified-record parent lookup is active yet. tests plus the everyday check. No identified-record parent lookup is active yet.
**Completion:** Activated collection-aware sequence operations at mapping-sibling
inheritance destinations, including identity transfer, destination-local modes,
record-owned policy projection, and application registrations. Verified focused
inheritance and sequence tests, `go test -race ./internal/engine`, and
`./scripts/check.sh`.
## Stage 19 — Build Identified-Record Inheritance Graphs ## Stage 19 — Build Identified-Record Inheritance Graphs
Scope: relationship extraction/validation and shared scheduling for sequence Scope: relationship extraction/validation and shared scheduling for sequence

View File

@@ -43,6 +43,7 @@ type inheritanceOperation struct {
parentPolicies []collectionPolicyScope parentPolicies []collectionPolicyScope
templatePolicies collectionPolicyScope templatePolicies collectionPolicyScope
effectivePolicies []collectionPolicyScope effectivePolicies []collectionPolicyScope
sequences []sequenceHistoryContribution
} }
func cloneInheritancePolicyTemplates(templates []collectionPolicyScope) []collectionPolicyScope { func cloneInheritancePolicyTemplates(templates []collectionPolicyScope) []collectionPolicyScope {
@@ -105,6 +106,7 @@ func cloneInheritanceOperations(operations []inheritanceOperation) []inheritance
cloned[index].parentPolicies = cloneInheritancePolicyTemplates(operation.parentPolicies) cloned[index].parentPolicies = cloneInheritancePolicyTemplates(operation.parentPolicies)
cloned[index].templatePolicies = cloneCollectionPolicyScopeForInheritance(operation.templatePolicies) cloned[index].templatePolicies = cloneCollectionPolicyScopeForInheritance(operation.templatePolicies)
cloned[index].effectivePolicies = cloneInheritancePolicyTemplates(operation.effectivePolicies) cloned[index].effectivePolicies = cloneInheritancePolicyTemplates(operation.effectivePolicies)
cloned[index].sequences = cloneSequenceHistoryContributions(operation.sequences)
} }
return cloned return cloned
} }

View File

@@ -0,0 +1,293 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func projectInheritanceCollectionPolicies(scope collectionPolicyScope, prefix Path) collectionPolicyScope {
projected := collectionPolicyScope{}
for _, entry := range scope.collections {
policy := cloneCollectionPolicyForInheritance(entry.policy)
stripInheritanceModes(&policy)
projected.set(appendDestination(prefix, entry.destination), policy)
}
return projected
}
func projectCurrentInheritanceCollectionPolicies(scope collectionPolicyScope, prefix Path) collectionPolicyScope {
projected := collectionPolicyScope{}
for _, entry := range scope.collections {
projected.set(appendDestination(prefix, entry.destination), cloneCollectionPolicyForInheritance(entry.policy))
}
return projected
}
func projectInheritancePolicyRoots(target *collectionPolicyScope, scope collectionPolicyScope, prefix Path) {
for _, entry := range scope.collections {
target.set(appendDestination(prefix, entry.destination), cloneCollectionPolicyForInheritance(entry.policy))
}
}
func stripInheritanceModes(policy *collectionPolicy) {
if policy == nil {
return
}
policy.explicitMode = 0
policy.explicitModePresent = false
policy.modeLocation = nil
for owner, nested := range policy.recordPolicies {
for key, entry := range nested.collections {
stripInheritanceModes(&entry.policy)
nested.collections[key] = entry
}
policy.recordPolicies[owner] = nested
}
}
func inheritanceDeclarationsAt(path Path, policy collectionPolicy) []sequenceDeclarationSpec {
if !policy.identityPresent && !policy.explicitModePresent {
return nil
}
return []sequenceDeclarationSpec{{
destination: path.clone(),
identityFields: cloneStrings(policy.identityFields), identityPresent: policy.identityPresent,
identityLocation: cloneLocation(policy.identityLocation),
mode: policy.explicitMode, modePresent: policy.explicitModePresent,
modeLocation: cloneLocation(policy.modeLocation),
}}
}
func inheritanceCollectionResolution(
path Path,
lowerScope, higherScope collectionPolicyScope,
applicationPolicy sequencePolicy,
) (collectionPolicyResolution, collectionPolicy, collectionPolicy, bool, []Diagnostic) {
lower, hasLower := lowerScope.at(path)
higher, hasHigher := higherScope.at(path)
applicationIdentity, registered := applicationPolicy.identityFieldsAt(path)
if !hasLower && !hasHigher && !registered {
return collectionPolicyResolution{}, collectionPolicy{}, collectionPolicy{}, false, nil
}
lowerForResolution := cloneCollectionPolicy(lower)
// The parent policy is copied to a different sibling destination. Its YAML
// identity follows the inherited collection, but its explicit mode does not.
lowerForResolution.explicitMode = 0
lowerForResolution.explicitModePresent = false
lowerForResolution.modeLocation = nil
var lowerPointer *collectionPolicy
if hasLower {
lowerPointer = &lowerForResolution
}
resolution, diagnostics := resolveCollectionPolicy(
path, inheritanceDeclarationsAt(path, higher), applicationIdentity, lowerPointer,
)
return resolution, lower, higher, true, diagnostics
}
func mergeInheritanceCollectionPolicies(
parent, child collectionPolicyScope,
parentRootPath Path,
parentValue *document.Node,
applicationPolicy sequencePolicy,
) (collectionPolicyScope, []Diagnostic) {
result := cloneCollectionPolicyScopeForInheritance(parent)
paths := make(map[string]Path, len(parent.collections)+len(child.collections))
for key, entry := range parent.collections {
paths[key] = entry.destination.clone()
}
for key, entry := range child.collections {
paths[key] = entry.destination.clone()
}
var diagnostics []Diagnostic
for _, path := range paths {
resolution, lower, _, active, issues := inheritanceCollectionResolution(path, parent, child, applicationPolicy)
if len(issues) != 0 {
diagnostics = append(diagnostics, issues...)
continue
}
if !active {
continue
}
var lowerPointer *collectionPolicy
if _, found := parent.at(path); found {
lower.explicitMode = 0
lower.explicitModePresent = false
lower.modeLocation = nil
lowerPointer = &lower
}
relative, inside := relativeHistoryPath(parentRootPath, path)
var retained *document.Node
if inside {
retained, _ = documentAtPath(parentValue, relative)
}
policy, foundDiagnostics := collectionPolicyWithoutValue(lowerPointer, resolution.policy, retained, path)
if len(foundDiagnostics) != 0 {
diagnostics = append(diagnostics, foundDiagnostics...)
continue
}
if higher, ok := child.at(path); ok {
policy = inheritanceRecordPoliciesFromHigher(policy, higher)
}
result.set(path, policy)
}
return result, canonicalDiagnostics(diagnostics)
}
func inheritanceRecordPoliciesFromHigher(policy, higher collectionPolicy) collectionPolicy {
if len(higher.recordOrder) == 0 {
return policy
}
policy.recordOrder = nil
policy.recordPolicies = nil
for index := range higher.recordOrder {
if record, found := higher.recordPolicyAt(index); found {
policy.setRecordPolicyAt(index, currentCollectionRecordPolicy(record))
}
}
return policy
}
func inheritedCollectionRecordPolicy(record collectionRecordPolicy) collectionRecordPolicy {
cloned := cloneCollectionPolicyForInheritance(collectionPolicy{
recordOrder: []*collectionRecordOwner{record.owner},
recordPolicies: map[*collectionRecordOwner]collectionPolicyScope{record.owner: record.scope},
})
if len(cloned.recordOrder) == 0 {
return newCollectionRecordPolicy()
}
owner := cloned.recordOrder[0]
scope := cloned.recordPolicies[owner]
for key, entry := range scope.collections {
stripInheritanceModes(&entry.policy)
scope.collections[key] = entry
}
return collectionRecordPolicy{owner: owner, scope: scope}
}
func currentCollectionRecordPolicy(record collectionRecordPolicy) collectionRecordPolicy {
cloned := cloneCollectionPolicyForInheritance(collectionPolicy{
recordOrder: []*collectionRecordOwner{record.owner},
recordPolicies: map[*collectionRecordOwner]collectionPolicyScope{record.owner: record.scope},
})
if len(cloned.recordOrder) == 0 {
return newCollectionRecordPolicy()
}
owner := cloned.recordOrder[0]
return collectionRecordPolicy{owner: owner, scope: cloned.recordPolicies[owner]}
}
func applyInheritanceSequencePolicy(
path Path,
combined sequenceCombinationResult,
resolution collectionPolicyResolution,
lowerScope, higherScope collectionPolicyScope,
result *collectionPolicyScope,
) collectionPolicy {
policy := cloneCollectionPolicyForInheritance(resolution.policy)
policy.recordOrder = nil
policy.recordPolicies = nil
if combined.value == nil || combined.value.Kind != document.Sequence {
result.retireCollectionPoliciesAtOrBelow(path)
return policy
}
lowerPolicy, hasLower := lowerScope.at(path)
higherPolicy, hasHigher := higherScope.at(path)
lowerProjection := newSequenceItemProjectionIndex(combined.lowerItems)
higherProjection := newSequenceItemProjectionIndex(combined.higherItems)
for outputIndex := range combined.value.Sequence {
if higherIndex, exists := higherProjection.inputForOutput(outputIndex); exists {
if record, found := higherPolicy.recordPolicyAt(higherIndex); hasHigher && found {
policy.setRecordPolicyAt(outputIndex, currentCollectionRecordPolicy(record))
} else {
policy.setRecordPolicyAt(outputIndex, replacedCollectionRecordPolicy())
}
continue
}
if lowerIndex, exists := lowerProjection.inputForOutput(outputIndex); exists && hasLower {
if record, found := lowerPolicy.recordPolicyAt(lowerIndex); found {
policy.setRecordPolicyAt(outputIndex, inheritedCollectionRecordPolicy(record))
}
}
}
return policy
}
func applyInheritanceSequenceOrigins(
result *inheritanceScope,
origins, incomingOrigins map[string]Origin,
path Path,
lower, higher *document.Node,
combined sequenceCombinationResult,
operation sequenceMode,
) {
if operation == sequenceModeReplace {
if lower != nil {
displaced := displacedOrigins(lower, origins, path)
result.replacements = append(result.replacements, inheritanceReplacement{
path: path.clone(), before: lower, after: higher,
previous: displaced, effective: cloneOrigin(incomingOrigins[path.String()]),
})
deleteOriginSubtree(origins, path, lower)
}
mergeInheritanceOrigin(origins, path, incomingOrigins[path.String()])
if higher != nil && higher.Kind != document.Sequence {
copyInheritanceSubtree(origins, incomingOrigins, higher, path)
}
for _, projection := range combined.higherItems {
if higher != nil && higher.Kind == document.Sequence && projection.inputIndex >= 0 && projection.inputIndex < len(higher.Sequence) {
copyInheritanceSubtree(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.outputIndex))
}
}
return
}
mergeInheritanceOrigin(origins, path, incomingOrigins[path.String()])
for _, replacement := range combined.replacements {
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
}
lowerPath := path.Index(replacement.lowerIndex)
higherPath := path.Index(replacement.higherIndex)
outputPath := path.Index(replacement.outputIndex)
previous := displacedOrigins(lower.Sequence[replacement.lowerIndex], origins, lowerPath)
deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex])
copyInheritanceSubtree(origins, incomingOrigins, higher.Sequence[replacement.higherIndex], outputPath)
result.replacements = append(result.replacements, inheritanceReplacement{
path: outputPath, before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex],
previous: previous, effective: cloneOrigin(incomingOrigins[higherPath.String()]),
})
}
for _, projection := range combined.higherItems {
if higher != nil && higher.Kind == document.Sequence && projection.inputIndex >= 0 && projection.inputIndex < len(higher.Sequence) {
copyInheritanceSubtree(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.outputIndex))
}
}
}
func validateRegisteredInheritanceSequences(root *document.Node, rootPath Path, applicationPolicy sequencePolicy) []Diagnostic {
var diagnostics []Diagnostic
var visit func(*document.Node, Path)
visit = func(node *document.Node, path Path) {
if node == nil {
return
}
if node.Kind == document.Sequence {
if fields, registered := applicationPolicy.identityFieldsAt(path); registered {
if _, issues := validateSequenceIdentity(node, path, fields, sequenceCombinationIdentityCodes); len(issues) != 0 {
diagnostics = append(diagnostics, issues...)
}
}
for index, item := range node.Sequence {
visit(item, path.Index(index))
}
return
}
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
visit(entry.Value, path.Key(entry.Key))
}
}
}
visit(root, rootPath)
return canonicalDiagnostics(diagnostics)
}

View File

@@ -0,0 +1,281 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestInheritanceCombinesIdentifiedSequencesAtDestination(t *testing.T) {
root := parseItemPolicyDocument(t, "inheritance-values", `base:
items:
- id: shared
value: parent
child:
extends: base
items:
- id: shared
value: child
`)
modeLocation := Location{SourceID: "base-policy", Line: 4, Column: 5}
policy := collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true, modeLocation: &modeLocation,
}
policies := collectionPolicyScope{}
policies.set(Keys("base", "items"), policy)
expanded, diagnostics := expandInheritanceWithCollectionRules(
root, nil, policies, sequencePolicy{}, defaultProjectionLimits(),
)
if len(diagnostics) != 0 {
t.Fatalf("expand identified inheritance: %#v", diagnostics)
}
items, found := documentAtPath(expanded.root, Keys("child", "items"))
if !found || items.Kind != document.Sequence || len(items.Sequence) != 1 {
t.Fatalf("child items = %#v, found %t; parent mode should not force an append", items, found)
}
if value, exists := documentAtPath(items.Sequence[0], Keys("value")); !exists || value.Text != "child" {
t.Fatalf("matched record = %#v, want complete child replacement", items.Sequence[0])
}
childPolicy, found := expanded.collectionPolicies.at(Keys("child", "items"))
if !found || !childPolicy.identityPresent || childPolicy.identityFields[0] != "id" {
t.Fatalf("inherited destination identity = %#v, found %t; scope=%#v", childPolicy, found, expanded.collectionPolicies.collections)
}
if childPolicy.explicitModePresent {
t.Fatalf("parent explicit mode leaked to a different destination: %#v", childPolicy)
}
var transfer *inheritanceOperation
for index := range expanded.operations {
if expanded.operations[index].transfer != nil && expanded.operations[index].transfer.to.String() == "/child" {
transfer = &expanded.operations[index]
break
}
}
if transfer == nil || len(transfer.sequences) != 1 || transfer.sequences[0].path.String() != "/child/items" || len(transfer.sequences[0].replacements) != 1 {
t.Fatalf("inheritance sequence projection = %#v, want one mapped replacement", transfer)
}
if origin, exists := expanded.origins["/child/items/0/value"]; !exists || len(origin.Locations) == 0 || origin.Locations[0].SourceID != "inheritance-values" {
t.Fatalf("replacement origin lost the child's source context: %#v", origin)
}
}
func TestLoadAppliesYamlCollectionIdentityDuringSiblingInheritance(t *testing.T) {
input := `configkit:
sequences:
- destination: [base, items]
key: id
mode: append
base:
items:
- id: shared
value: parent
child:
extends: base
items:
- id: shared
value: child
`
loaded, err := Load[map[string]any](Bytes("inheritance-policy", []byte(input)), testSecretBinding())
if err != nil {
if loadError, ok := err.(*LoadError); ok {
t.Fatalf("load inherited collection policy: %v; diagnostics=%#v", err, loadError.Diagnostics())
}
t.Fatal(err)
}
value, found := loaded.Inspection.Explain(Keys("child", "items").Index(0).Key("value"))
if !found || value.Effective.YAML != "child\n" {
t.Fatalf("inherited sequence value = %#v, found %t", value, found)
}
if len(value.History) == 0 {
t.Fatal("inherited sequence replacement has no inspection history")
}
parentValueVisible, childValueVisible := false, false
for _, change := range value.History {
parentValueVisible = parentValueVisible || change.Before.YAML == "parent\n" || change.After.YAML == "parent\n"
childValueVisible = childValueVisible || change.After.YAML == "child\n"
}
if !parentValueVisible || !childValueVisible {
t.Fatalf("sequence replacement history omitted its parent or child value: %#v", value.History)
}
}
func TestInheritanceUsesDestinationExplicitModeAndApplicationConstraint(t *testing.T) {
t.Run("destination append", func(t *testing.T) {
root := parseItemPolicyDocument(t, "append", `base:
items:
- id: base
child:
extends: base
items:
- id: child
`)
policies := collectionPolicyScope{}
policies.set(Keys("base", "items"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
childLocation := Location{SourceID: "child-policy", Line: 4, Column: 5}
policies.set(Keys("child", "items"), collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true, modeLocation: &childLocation,
})
expanded, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatalf("expand with destination append: %#v", diagnostics)
}
items, _ := documentAtPath(expanded.root, Keys("child", "items"))
if len(items.Sequence) != 2 || items.Sequence[0].Mapping[0].Value.Text != "base" || items.Sequence[1].Mapping[0].Value.Text != "child" {
t.Fatalf("appended items = %#v", items)
}
})
t.Run("new matching namespace", func(t *testing.T) {
root := parseItemPolicyDocument(t, "dynamic", `base:
enabled: true
child:
extends: base
extensions:
- id: duplicate
- id: duplicate
`)
applicationPolicy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
selector: sequenceIdentitySelector{
{kind: sequenceSelectorLiteral, key: "child"},
{kind: sequenceSelectorLiteral, key: "extensions"},
},
identityFields: []string{"id"},
}})
if len(diagnostics) != 0 {
t.Fatalf("compile identity constraint: %#v", diagnostics)
}
_, diagnostics = expandInheritanceWithCollectionRules(root, nil, collectionPolicyScope{}, applicationPolicy, defaultProjectionLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/child/extensions" {
t.Fatalf("dynamic application constraint diagnostics = %#v", diagnostics)
}
})
}
func TestInheritanceReplacementModesAndCancellation(t *testing.T) {
for _, test := range []struct {
name string
value string
kind document.Kind
}{
{name: "empty sequence", value: "[]", kind: document.Sequence},
{name: "null", value: "null", kind: document.Null},
} {
t.Run(test.name, func(t *testing.T) {
root := parseItemPolicyDocument(t, "replacement", "base:\n items:\n - id: prior\nchild:\n extends: base\n items: "+test.value+"\n")
policies := collectionPolicyScope{}
policies.set(Keys("base", "items"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(Keys("child", "items"), collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeReplace, explicitModePresent: true,
})
expanded, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatalf("expand replacement: %#v", diagnostics)
}
items, found := documentAtPath(expanded.root, Keys("child", "items"))
if !found || items.Kind != test.kind || (items.Kind == document.Sequence && len(items.Sequence) != 0) {
t.Fatalf("replacement value = %#v, found %t", items, found)
}
})
}
for _, value := range []string{"null", "{}"} {
t.Run("append rejects "+value, func(t *testing.T) {
root := parseItemPolicyDocument(t, "append-kind", "base:\n items: [{id: prior}]\nchild:\n extends: base\n items: "+value+"\n")
policies := collectionPolicyScope{}
policies.set(Keys("base", "items"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(Keys("child", "items"), collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
})
_, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/child/items" {
t.Fatalf("append kind diagnostics = %#v", diagnostics)
}
})
}
t.Run("incompatible inherited identity", func(t *testing.T) {
root := parseItemPolicyDocument(t, "identity", `base:
items: [{id: one}]
child:
extends: base
items: [{id: two}]
`)
policies := collectionPolicyScope{}
policies.set(Keys("base", "items"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(Keys("child", "items"), collectionPolicy{identityFields: []string{"slug"}, identityPresent: true})
_, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/child/items" {
t.Fatalf("identity conflict diagnostics = %#v", diagnostics)
}
})
t.Run("cancelled relationship", func(t *testing.T) {
root := parseItemPolicyDocument(t, "cancel", `base:
items: [{id: parent}]
child:
extends: null
items: [{id: local}]
`)
policies := collectionPolicyScope{}
policies.set(Keys("base", "items"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
expanded, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatalf("expand cancelled relationship: %#v", diagnostics)
}
if _, found := expanded.collectionPolicies.at(Keys("child", "items")); found {
t.Fatal("cancelled relationship transferred the parent's collection policy")
}
})
}
func TestInheritanceReplacementRetiresRecordLocalCollectionPolicy(t *testing.T) {
root := parseItemPolicyDocument(t, "record-scope", `base:
items:
- id: shared
children:
- key: old
child:
extends: base
items:
- id: shared
children:
- key: new
`)
nested := collectionPolicy{identityFields: []string{"key"}, identityPresent: true, explicitMode: sequenceModeAppend, explicitModePresent: true}
recordScope := collectionPolicyScope{}
recordScope.set(Keys("children"), nested)
outer := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
outer.setRecordPolicyAt(0, newCollectionRecordPolicy())
owner := outer.recordOrder[0]
outer.recordPolicies[owner] = recordScope
policies := collectionPolicyScope{}
policies.set(Keys("base", "items"), outer)
expanded, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatalf("expand replacement record: %#v", diagnostics)
}
children, found := documentAtPath(expanded.root, Keys("child", "items").Index(0).Key("children"))
if !found || children.Kind != document.Sequence || len(children.Sequence) != 1 {
t.Fatalf("replacement children = %#v, found %t", children, found)
}
if key, exists := documentAtPath(children.Sequence[0], Keys("key")); !exists || key.Text != "new" {
t.Fatalf("replacement child = %#v, want only new record", children.Sequence[0])
}
childPolicy, exists := expanded.collectionPolicies.at(Keys("child", "items"))
if !exists {
t.Fatal("inherited outer collection policy is missing")
}
record, found := childPolicy.recordPolicyAt(0)
if !found {
t.Fatal("replacement item has no independent record owner")
}
if nestedPolicy, exists := record.scope.at(Keys("children")); exists && nestedPolicy.explicitModePresent {
t.Fatalf("replaced record retained its former nested operation: %#v", nestedPolicy)
}
}

View File

@@ -21,6 +21,8 @@ type inheritanceTemplate struct {
type inheritanceGraph struct { type inheritanceGraph struct {
scope *document.Node scope *document.Node
scopePath Path scopePath Path
collectionPolicies collectionPolicyScope
applicationPolicy sequencePolicy
nodes []inheritanceTemplate nodes []inheritanceTemplate
edges []dependencyEdge edges []dependencyEdge
byName map[string]int byName map[string]int
@@ -34,6 +36,10 @@ func analyzeInheritanceGraph(scope *document.Node, scopePath Path) (*inheritance
} }
func analyzeInheritanceGraphWithPolicies(scope *document.Node, scopePath Path, policies collectionPolicyScope) (*inheritanceGraph, []Diagnostic) { func analyzeInheritanceGraphWithPolicies(scope *document.Node, scopePath Path, policies collectionPolicyScope) (*inheritanceGraph, []Diagnostic) {
return analyzeInheritanceGraphWithCollectionRules(scope, scopePath, policies, sequencePolicy{})
}
func analyzeInheritanceGraphWithCollectionRules(scope *document.Node, scopePath Path, policies collectionPolicyScope, applicationPolicy sequencePolicy) (*inheritanceGraph, []Diagnostic) {
if scope == nil || scope.Kind != document.Mapping { if scope == nil || scope.Kind != document.Mapping {
return nil, []Diagnostic{{ return nil, []Diagnostic{{
Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath, Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath,
@@ -43,6 +49,7 @@ func analyzeInheritanceGraphWithPolicies(scope *document.Node, scopePath Path, p
graph := &inheritanceGraph{ graph := &inheritanceGraph{
scope: scope, scopePath: scopePath, scope: scope, scopePath: scopePath,
collectionPolicies: cloneCollectionPolicyScopeForInheritance(policies), applicationPolicy: applicationPolicy,
byName: make(map[string]int, len(scope.Mapping)), byName: make(map[string]int, len(scope.Mapping)),
} }
entries := make(map[string]document.Entry, len(scope.Mapping)) entries := make(map[string]document.Entry, len(scope.Mapping))
@@ -125,7 +132,11 @@ func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, l
} }
func expandSiblingDefinitionsWithPolicies(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, policies collectionPolicyScope, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) { func expandSiblingDefinitionsWithPolicies(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, policies collectionPolicyScope, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) {
graph, diagnostics := analyzeInheritanceGraphWithPolicies(scope, scopePath, policies) return expandSiblingDefinitionsWithCollectionRules(scope, scopePath, limits, inputOrigins, policies, sequencePolicy{}, budget...)
}
func expandSiblingDefinitionsWithCollectionRules(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, policies collectionPolicyScope, applicationPolicy sequencePolicy, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) {
graph, diagnostics := analyzeInheritanceGraphWithCollectionRules(scope, scopePath, policies, applicationPolicy)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, diagnostics return nil, diagnostics
} }
@@ -137,7 +148,6 @@ func expandSiblingDefinitionsWithPolicies(scope *document.Node, scopePath Path,
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, diagnostics return nil, diagnostics
} }
result.collectionPolicies = cloneCollectionPolicyScopeForInheritance(policies)
return result, nil return result, nil
} }
@@ -148,6 +158,7 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj
resolved := make([]*document.Node, len(graph.nodes)) resolved := make([]*document.Node, len(graph.nodes))
resolvedOrigins := make([]map[string]Origin, len(graph.nodes)) resolvedOrigins := make([]map[string]Origin, len(graph.nodes))
resolvedPolicyTemplates := make([][]collectionPolicyScope, len(graph.nodes)) resolvedPolicyTemplates := make([][]collectionPolicyScope, len(graph.nodes))
resolvedPolicies := make([]collectionPolicyScope, len(graph.nodes))
result := &inheritanceScope{} result := &inheritanceScope{}
counter := newRetainedNodeCounter(historyNodeLimit) counter := newRetainedNodeCounter(historyNodeLimit)
if len(budget) != 0 { if len(budget) != 0 {
@@ -202,16 +213,28 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj
parent := resolved[node.parent] parent := resolved[node.parent]
parentPolicies := cloneInheritancePolicyTemplates(resolvedPolicyTemplates[node.parent]) parentPolicies := cloneInheritancePolicyTemplates(resolvedPolicyTemplates[node.parent])
effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies)) effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies))
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path)
childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path)
merged := document.Clone(parent) merged := document.Clone(parent)
mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
parentPolicyScope, childPolicyScope, node.path, parent, graph.applicationPolicy,
)
if len(policyDiagnostics) != 0 {
return nil, policyDiagnostics
}
transfer := inheritanceTransfer{ transfer := inheritanceTransfer{
from: graph.nodes[node.parent].path, to: node.path, from: graph.nodes[node.parent].path, to: node.path,
reference: node.path.Key(inheritanceKey), reference: node.path.Key(inheritanceKey),
locations: []Location{publicLocation(node.directive.Value.Location)}, locations: []Location{publicLocation(node.directive.Value.Location)},
policyTemplates: parentPolicies, templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies), policyTemplates: parentPolicies, templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies),
projectedPolicies: projectInheritancePolicyTemplates(parentPolicies, node.path), projectedPolicies: projectInheritancePolicyTemplates(
[]collectionPolicyScope{resolvedPolicies[node.parent]}, node.path,
),
} }
mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[node.parent], transfer.from, node.path, transfer) mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[node.parent], transfer.from, node.path, transfer)
mergeInheritanceOrigin(mergedOrigins, node.path, childOrigins[node.path.String()]) mergeInheritanceOrigin(mergedOrigins, node.path, childOrigins[node.path.String()])
var sequenceContributions []sequenceHistoryContribution
var combinationDiagnostics []Diagnostic
mergeRecursiveMappings(merged, payload, node.path, new(mappingIndex), recursiveMergeHooks{ mergeRecursiveMappings(merged, payload, node.path, new(mappingIndex), recursiveMergeHooks{
added: func(childPath Path, incoming *document.Node) { added: func(childPath Path, incoming *document.Node) {
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath) copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
@@ -228,9 +251,57 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj
deleteOriginSubtree(mergedOrigins, childPath, existing) deleteOriginSubtree(mergedOrigins, childPath, existing)
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath) copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
}, },
merge: func(childPath Path, existing, incoming *document.Node) (*document.Node, bool) {
resolution, _, _, active, issues := inheritanceCollectionResolution(
childPath, parentPolicyScope, childPolicyScope, graph.applicationPolicy,
)
if len(issues) != 0 {
combinationDiagnostics = append(combinationDiagnostics, issues...)
return existing, false
}
if !active {
return nil, false
}
lowerPath, within := relativeHistoryPath(node.path, childPath)
if !within {
return nil, false
}
lowerValue, _ := documentAtPath(parent, lowerPath)
combined, problems := combineSequence(lowerValue, incoming, resolution, sequenceCombinationContext{destination: childPath})
if len(problems) != 0 {
combinationDiagnostics = append(combinationDiagnostics, problems...)
return existing, false
}
policy := applyInheritanceSequencePolicy(
childPath, combined, resolution, parentPolicyScope, childPolicyScope, &mergedPolicyScope,
)
mergedPolicyScope.set(childPath, policy)
applyInheritanceSequenceOrigins(
result, mergedOrigins, childOrigins, childPath, lowerValue, incoming, combined, resolution.operation,
)
history := sequenceHistoryContribution{path: childPath.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...)
}
sequenceContributions = append(sequenceContributions, history)
return combined.value, true
},
}) })
if len(combinationDiagnostics) != 0 {
return nil, canonicalDiagnostics(combinationDiagnostics)
}
if problems := validateRegisteredInheritanceSequences(merged, node.path, graph.applicationPolicy); len(problems) != 0 {
return nil, problems
}
merged.Location = node.entry.Value.Location merged.Location = node.entry.Value.Location
resolved[index], resolvedOrigins[index] = merged, mergedOrigins resolved[index], resolvedOrigins[index] = merged, mergedOrigins
resolvedPolicies[index] = inheritancePolicyScopeAtPath(mergedPolicyScope, node.path)
result.transfers = append(result.transfers, transfer) result.transfers = append(result.transfers, transfer)
result.operations = append(result.operations, inheritanceOperation{ result.operations = append(result.operations, inheritanceOperation{
transfer: &transfer, parent: parent, effective: merged, transfer: &transfer, parent: parent, effective: merged,
@@ -238,12 +309,14 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj
parentPolicies: cloneInheritancePolicyTemplates(parentPolicies), parentPolicies: cloneInheritancePolicyTemplates(parentPolicies),
templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies), templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies),
effectivePolicies: cloneInheritancePolicyTemplates(effectivePolicies), effectivePolicies: cloneInheritancePolicyTemplates(effectivePolicies),
sequences: cloneSequenceHistoryContributions(sequenceContributions),
}) })
resolvedPolicyTemplates[index] = effectivePolicies resolvedPolicyTemplates[index] = effectivePolicies
} else { } else {
resolved[index] = document.Clone(payload) resolved[index] = document.Clone(payload)
resolvedOrigins[index] = childOrigins resolvedOrigins[index] = childOrigins
resolvedPolicyTemplates[index] = []collectionPolicyScope{templatePolicies} resolvedPolicyTemplates[index] = []collectionPolicyScope{templatePolicies}
resolvedPolicies[index] = cloneCollectionPolicyScopeForInheritance(templatePolicies)
} }
count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes) count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes)
@@ -262,17 +335,21 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj
root := *graph.scope root := *graph.scope
root.Mapping = make([]document.Entry, len(graph.scope.Mapping)) root.Mapping = make([]document.Entry, len(graph.scope.Mapping))
result.collectionPolicies = collectionPolicyScope{}
result.origins = directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: graph.scope.Location}, graph.scopePath, inputOrigins) result.origins = directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: graph.scope.Location}, graph.scopePath, inputOrigins)
for i, entry := range graph.scope.Mapping { for i, entry := range graph.scope.Mapping {
root.Mapping[i] = entry root.Mapping[i] = entry
if index, found := graph.byName[entry.Key]; found { if index, found := graph.byName[entry.Key]; found {
root.Mapping[i].Value = resolved[index] root.Mapping[i].Value = resolved[index]
projectInheritancePolicyRoots(&result.collectionPolicies, resolvedPolicies[index], graph.nodes[index].path)
for pointer, origin := range resolvedOrigins[index] { for pointer, origin := range resolvedOrigins[index] {
result.origins[pointer] = origin result.origins[pointer] = origin
} }
continue continue
} }
root.Mapping[i].Value = document.Clone(entry.Value) root.Mapping[i].Value = document.Clone(entry.Value)
localPolicies := inheritancePolicyScopeAtPath(graph.collectionPolicies, graph.scopePath.Key(entry.Key))
projectInheritancePolicyRoots(&result.collectionPolicies, localPolicies, graph.scopePath.Key(entry.Key))
for pointer, origin := range directInheritanceOrigins(entry.Value, graph.scopePath.Key(entry.Key), inputOrigins) { for pointer, origin := range directInheritanceOrigins(entry.Value, graph.scopePath.Key(entry.Key), inputOrigins) {
result.origins[pointer] = origin result.origins[pointer] = origin
} }

View File

@@ -11,6 +11,7 @@ type inheritanceWalker struct {
nodes int // logical non-directive nodes in the entire working tree nodes int // logical non-directive nodes in the entire working tree
origins map[string]Origin origins map[string]Origin
collectionPolicies collectionPolicyScope collectionPolicies collectionPolicyScope
applicationPolicy sequencePolicy
limits projectionLimits limits projectionLimits
retained *retainedNodeCounter retained *retainedNodeCounter
transfers []inheritanceTransfer transfers []inheritanceTransfer
@@ -34,10 +35,18 @@ func expandInheritanceWithBudgets(input *document.Node, inputOrigins map[string]
} }
func expandInheritanceWithPolicies(input *document.Node, inputOrigins map[string]Origin, policies collectionPolicyScope, limits projectionLimits) (*inheritanceScope, []Diagnostic) { func expandInheritanceWithPolicies(input *document.Node, inputOrigins map[string]Origin, policies collectionPolicyScope, limits projectionLimits) (*inheritanceScope, []Diagnostic) {
return expandInheritanceWithPolicyBudgets(input, inputOrigins, policies, limits, historyNodeLimit) return expandInheritanceWithCollectionRules(input, inputOrigins, policies, sequencePolicy{}, limits)
} }
func expandInheritanceWithPolicyBudgets(input *document.Node, inputOrigins map[string]Origin, policies collectionPolicyScope, limits projectionLimits, historyLimit int) (*inheritanceScope, []Diagnostic) { func expandInheritanceWithPolicyBudgets(input *document.Node, inputOrigins map[string]Origin, policies collectionPolicyScope, limits projectionLimits, historyLimit int) (*inheritanceScope, []Diagnostic) {
return expandInheritanceWithCollectionRuleBudgets(input, inputOrigins, policies, sequencePolicy{}, limits, historyLimit)
}
func expandInheritanceWithCollectionRules(input *document.Node, inputOrigins map[string]Origin, policies collectionPolicyScope, applicationPolicy sequencePolicy, limits projectionLimits) (*inheritanceScope, []Diagnostic) {
return expandInheritanceWithCollectionRuleBudgets(input, inputOrigins, policies, applicationPolicy, limits, historyNodeLimit)
}
func expandInheritanceWithCollectionRuleBudgets(input *document.Node, inputOrigins map[string]Origin, policies collectionPolicyScope, applicationPolicy sequencePolicy, limits projectionLimits, historyLimit int) (*inheritanceScope, []Diagnostic) {
if input == nil || input.Kind != document.Mapping { if input == nil || input.Kind != document.Mapping {
return nil, []Diagnostic{{Code: CodeInheritanceInvalid, Severity: SeverityError, return nil, []Diagnostic{{Code: CodeInheritanceInvalid, Severity: SeverityError,
Message: "inheritance requires a mapping document root"}} Message: "inheritance requires a mapping document root"}}
@@ -54,6 +63,7 @@ func expandInheritanceWithPolicyBudgets(input *document.Node, inputOrigins map[s
walker := inheritanceWalker{ walker := inheritanceWalker{
nodes: nodes, limits: limits, origins: origins, nodes: nodes, limits: limits, origins: origins,
collectionPolicies: cloneCollectionPolicyScopeForInheritance(policies), collectionPolicies: cloneCollectionPolicyScopeForInheritance(policies),
applicationPolicy: applicationPolicy,
retained: newRetainedNodeCounter(historyLimit), retained: newRetainedNodeCounter(historyLimit),
} }
if problem := walker.visit(&root, Path{}, true); problem != nil { if problem := walker.visit(&root, Path{}, true); problem != nil {
@@ -102,11 +112,21 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
if limits.nodes >= 0 { if limits.nodes >= 0 {
limits.nodes -= w.nodes - before limits.nodes -= w.nodes - before
} }
resolved, diagnostics := expandSiblingDefinitionsWithPolicies(*node, path, limits, w.origins, w.collectionPolicies, w.retained) resolved, diagnostics := expandSiblingDefinitionsWithCollectionRules(
*node, path, limits, w.origins, w.collectionPolicies, w.applicationPolicy, w.retained,
)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return &diagnostics[0] return &diagnostics[0]
} }
*node = resolved.root *node = resolved.root
for pointer, entry := range w.collectionPolicies.collections {
if pathHasPrefix(entry.destination, path) {
delete(w.collectionPolicies.collections, pointer)
}
}
for _, entry := range resolved.collectionPolicies.collections {
w.collectionPolicies.set(entry.destination, entry.policy)
}
deleteOriginSubtree(w.origins, path, original) deleteOriginSubtree(w.origins, path, original)
for pointer, origin := range resolved.origins { for pointer, origin := range resolved.origins {
w.origins[pointer] = origin w.origins[pointer] = origin

View File

@@ -258,8 +258,8 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
accumulator.add(problems...) accumulator.add(problems...)
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
expanded, problems := expandInheritanceWithPolicies( expanded, problems := expandInheritanceWithCollectionRules(
composition.root, composition.origins, composition.collectionPolicies, defaultProjectionLimits(), composition.root, composition.origins, composition.collectionPolicies, request.sequencePolicy, defaultProjectionLimits(),
) )
if len(problems) != 0 { if len(problems) != 0 {
accumulator.add(problems...) accumulator.add(problems...)