diff --git a/docs/config.md b/docs/config.md index 25c2662..69d5196 100644 --- a/docs/config.md +++ b/docs/config.md @@ -248,8 +248,14 @@ between tiers before resolving relationships. An omitted `extends` retains a lower-priority relationship; `extends: null` cancels it while preserving the child's own settings. Superseded references are historical evidence, not active 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. +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 entries the application may never select for execution. Partial parent 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 composition. Overlay declarations apply after the base source graph is composed 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 no declaration scope. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 82d681c..a49edb4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # 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 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 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 Scope: relationship extraction/validation and shared scheduling for sequence diff --git a/internal/engine/inheritance.go b/internal/engine/inheritance.go index 47251c7..d906177 100644 --- a/internal/engine/inheritance.go +++ b/internal/engine/inheritance.go @@ -43,6 +43,7 @@ type inheritanceOperation struct { parentPolicies []collectionPolicyScope templatePolicies collectionPolicyScope effectivePolicies []collectionPolicyScope + sequences []sequenceHistoryContribution } func cloneInheritancePolicyTemplates(templates []collectionPolicyScope) []collectionPolicyScope { @@ -105,6 +106,7 @@ func cloneInheritanceOperations(operations []inheritanceOperation) []inheritance cloned[index].parentPolicies = cloneInheritancePolicyTemplates(operation.parentPolicies) cloned[index].templatePolicies = cloneCollectionPolicyScopeForInheritance(operation.templatePolicies) cloned[index].effectivePolicies = cloneInheritancePolicyTemplates(operation.effectivePolicies) + cloned[index].sequences = cloneSequenceHistoryContributions(operation.sequences) } return cloned } diff --git a/internal/engine/inheritance_collection.go b/internal/engine/inheritance_collection.go new file mode 100644 index 0000000..b04d2af --- /dev/null +++ b/internal/engine/inheritance_collection.go @@ -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) +} diff --git a/internal/engine/inheritance_collection_test.go b/internal/engine/inheritance_collection_test.go new file mode 100644 index 0000000..64affad --- /dev/null +++ b/internal/engine/inheritance_collection_test.go @@ -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) + } +} diff --git a/internal/engine/inheritance_schedule.go b/internal/engine/inheritance_schedule.go index cb13935..a59d02d 100644 --- a/internal/engine/inheritance_schedule.go +++ b/internal/engine/inheritance_schedule.go @@ -19,11 +19,13 @@ type inheritanceTemplate struct { } type inheritanceGraph struct { - scope *document.Node - scopePath Path - nodes []inheritanceTemplate - edges []dependencyEdge - byName map[string]int + scope *document.Node + scopePath Path + collectionPolicies collectionPolicyScope + applicationPolicy sequencePolicy + nodes []inheritanceTemplate + edges []dependencyEdge + byName map[string]int } // analyzeInheritanceGraph retains only direct mapping siblings as graph nodes. @@ -34,6 +36,10 @@ func analyzeInheritanceGraph(scope *document.Node, scopePath Path) (*inheritance } 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 { return nil, []Diagnostic{{ Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath, @@ -43,6 +49,7 @@ func analyzeInheritanceGraphWithPolicies(scope *document.Node, scopePath Path, p graph := &inheritanceGraph{ scope: scope, scopePath: scopePath, + collectionPolicies: cloneCollectionPolicyScopeForInheritance(policies), applicationPolicy: applicationPolicy, byName: make(map[string]int, 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) { - 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 { return nil, diagnostics } @@ -137,7 +148,6 @@ func expandSiblingDefinitionsWithPolicies(scope *document.Node, scopePath Path, if len(diagnostics) != 0 { return nil, diagnostics } - result.collectionPolicies = cloneCollectionPolicyScopeForInheritance(policies) return result, nil } @@ -148,6 +158,7 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj resolved := make([]*document.Node, len(graph.nodes)) resolvedOrigins := make([]map[string]Origin, len(graph.nodes)) resolvedPolicyTemplates := make([][]collectionPolicyScope, len(graph.nodes)) + resolvedPolicies := make([]collectionPolicyScope, len(graph.nodes)) result := &inheritanceScope{} counter := newRetainedNodeCounter(historyNodeLimit) if len(budget) != 0 { @@ -202,16 +213,28 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj parent := resolved[node.parent] parentPolicies := cloneInheritancePolicyTemplates(resolvedPolicyTemplates[node.parent]) effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies)) + parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path) + childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path) merged := document.Clone(parent) + mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies( + parentPolicyScope, childPolicyScope, node.path, parent, graph.applicationPolicy, + ) + if len(policyDiagnostics) != 0 { + return nil, policyDiagnostics + } transfer := inheritanceTransfer{ from: graph.nodes[node.parent].path, to: node.path, reference: node.path.Key(inheritanceKey), locations: []Location{publicLocation(node.directive.Value.Location)}, 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) mergeInheritanceOrigin(mergedOrigins, node.path, childOrigins[node.path.String()]) + var sequenceContributions []sequenceHistoryContribution + var combinationDiagnostics []Diagnostic mergeRecursiveMappings(merged, payload, node.path, new(mappingIndex), recursiveMergeHooks{ added: func(childPath Path, incoming *document.Node) { copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath) @@ -228,9 +251,57 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj deleteOriginSubtree(mergedOrigins, childPath, existing) 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 resolved[index], resolvedOrigins[index] = merged, mergedOrigins + resolvedPolicies[index] = inheritancePolicyScopeAtPath(mergedPolicyScope, node.path) result.transfers = append(result.transfers, transfer) result.operations = append(result.operations, inheritanceOperation{ transfer: &transfer, parent: parent, effective: merged, @@ -238,12 +309,14 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj parentPolicies: cloneInheritancePolicyTemplates(parentPolicies), templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies), effectivePolicies: cloneInheritancePolicyTemplates(effectivePolicies), + sequences: cloneSequenceHistoryContributions(sequenceContributions), }) resolvedPolicyTemplates[index] = effectivePolicies } else { resolved[index] = document.Clone(payload) resolvedOrigins[index] = childOrigins resolvedPolicyTemplates[index] = []collectionPolicyScope{templatePolicies} + resolvedPolicies[index] = cloneCollectionPolicyScopeForInheritance(templatePolicies) } count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes) @@ -262,17 +335,21 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj root := *graph.scope 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) for i, entry := range graph.scope.Mapping { root.Mapping[i] = entry if index, found := graph.byName[entry.Key]; found { root.Mapping[i].Value = resolved[index] + projectInheritancePolicyRoots(&result.collectionPolicies, resolvedPolicies[index], graph.nodes[index].path) for pointer, origin := range resolvedOrigins[index] { result.origins[pointer] = origin } continue } 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) { result.origins[pointer] = origin } diff --git a/internal/engine/inheritance_walk.go b/internal/engine/inheritance_walk.go index 8611b46..21dd119 100644 --- a/internal/engine/inheritance_walk.go +++ b/internal/engine/inheritance_walk.go @@ -11,6 +11,7 @@ type inheritanceWalker struct { nodes int // logical non-directive nodes in the entire working tree origins map[string]Origin collectionPolicies collectionPolicyScope + applicationPolicy sequencePolicy limits projectionLimits retained *retainedNodeCounter 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) { - 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) { + 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 { return nil, []Diagnostic{{Code: CodeInheritanceInvalid, Severity: SeverityError, Message: "inheritance requires a mapping document root"}} @@ -54,6 +63,7 @@ func expandInheritanceWithPolicyBudgets(input *document.Node, inputOrigins map[s walker := inheritanceWalker{ nodes: nodes, limits: limits, origins: origins, collectionPolicies: cloneCollectionPolicyScopeForInheritance(policies), + applicationPolicy: applicationPolicy, retained: newRetainedNodeCounter(historyLimit), } 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 { 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 { return &diagnostics[0] } *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) for pointer, origin := range resolved.origins { w.origins[pointer] = origin diff --git a/internal/engine/load.go b/internal/engine/load.go index acd7e46..e28555c 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -258,8 +258,8 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo accumulator.add(problems...) return nil, accumulator.loadError() } - expanded, problems := expandInheritanceWithPolicies( - composition.root, composition.origins, composition.collectionPolicies, defaultProjectionLimits(), + expanded, problems := expandInheritanceWithCollectionRules( + composition.root, composition.origins, composition.collectionPolicies, request.sequencePolicy, defaultProjectionLimits(), ) if len(problems) != 0 { accumulator.add(problems...)