From 9a7027a04c1e17c85c82864e683d93caccf43a92 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 23 Oct 2026 19:12:39 +0000 Subject: [PATCH] Activate ordinary source collection declarations --- docs/config.md | 59 ++++-- docs/roadmap/implementation.md | 6 +- example_test.go | 22 +++ internal/engine/directives.go | 69 +++++++ internal/engine/imports.go | 46 +++-- internal/engine/load.go | 2 + internal/engine/load_input.go | 53 +++++- internal/engine/ordinary_sequence_test.go | 210 ++++++++++++++++++++++ internal/engine/source_version.go | 22 ++- 9 files changed, 448 insertions(+), 41 deletions(-) create mode 100644 internal/engine/ordinary_sequence_test.go diff --git a/docs/config.md b/docs/config.md index fc9fad2..cd91b06 100644 --- a/docs/config.md +++ b/docs/config.md @@ -52,7 +52,8 @@ preserves already validated secret state without rereading the environment. The current lifecycle is: ```text -validate options → parse the selected source → traverse/compose enabled imports +validate options → parse the selected source → extract source controls and + traverse/strictly compose enabled imports → build schema → check the optional application version → apply a selected overlay → apply explicit CLI overrides → expand sibling inheritance → create and clone defaults → strictly decode @@ -75,10 +76,11 @@ may register strict sequence composition paths through at the destination; `sequence-item` explicitly wraps the mapping as one item at a non-root destination. Unknown modes, invalid destinations, and invalid or conflicting sequence registrations fail before source acquisition. Overlay and -CLI coordinates remain rooted at the application root. `NoSource` rejects +CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes +source-root collection declarations whether or not imports are enabled; an +`imports` declaration still requires imports to be enabled. `NoSource` rejects imports, active overlays, and filesystem context as invalid combinations but -accepts overrides. Input containing a reserved `configkit` mapping key is rejected -while imports are disabled. +accepts overrides and remains an absent source rather than an empty document. Every source, options, schema, version, defaults, decode, resolution, or validation failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached @@ -767,8 +769,11 @@ live services as a substitute for configuration validation. ## Strict document composition -ConfigKit strictly composes parsed base documents when `Options.Imports` is true. -Application defaults do not participate in this operation. +Ordinary `Load` applies strict document composition to its parsed source scope; +`Options.Imports` adds imported documents as peers in that scope. Application +defaults do not participate. With imports disabled, the single source still has +its controls extracted and any declared or registered collection identity is +validated, but there are no imported peers to combine. | Existing node | Incoming node | Result | | --- | --- | --- | @@ -779,11 +784,11 @@ Application defaults do not participate in this operation. | Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` | Every non-mapping overlap is a conflict, even when values are equal, except for -sequence/sequence contributions at a registered sequence-composition path. Nulls -remain atomic conflicts. Unregistered sequences conflict when repeated and are -never joined by index. Disjoint entries under shared mappings compose, making -stable keyed mappings the natural representation for independently contributed -entries. +sequence/sequence contributions at a registered sequence-composition path or a +source-declared collection destination. Nulls remain atomic conflicts. +Unregistered and undeclared sequences conflict when repeated and are never joined +by index. Disjoint entries under shared mappings compose, making stable keyed +mappings the natural representation for independently contributed entries. `Definition.SequenceCompositions` registers literal mapping paths and one literal `IdentityField` for sequences that should compose independently authored records. @@ -819,6 +824,26 @@ headers are consumed before strict composition and cannot supply a registered record identity. Final typed decoding and application validation still run on the completed configuration. +Ordinary source documents may also declare collection behavior under the root +`configkit.sequences` key. Each entry has a required literal `destination` and may +specify `key` as one string or a nonempty list of strings, plus a `mode` of +`replace`, `append`, or `upsert`. These controls are removed before application +decoding. A declaration destination is relative to that source's placement, so a +mounted fragment's declaration is mounted with its values. Declarations from +strict peers must agree on identity fields and on any explicitly supplied mode; +they apply even when the declaring source has no value at that destination. +Singleton sequence contributions are validated as well as peers. A key supplies +the identity used by `append` and `upsert`; without an explicit or retained mode, +a keyed collection uses `upsert`, while an unkeyed collection uses `replace`. +`append` keeps contribution order and rejects repeated identities. `upsert` +replaces a complete matched record at its existing position and appends new +identities in incoming order; it does not merge record fields. + +At this boundary, declarations govern ordinary strict source composition, +including loads with imports disabled. They do not change selected-overlay or +layered precedence, inheritance, or CLI behavior. A root `configkit.imports` +declaration still requires `Options.Imports`; `NoSource` has no declaration scope. + An empty mapping contributes no child definitions. Two empty mappings compose, and an empty mapping neither clears nor conflicts with another mapping's children. Shared mapping containers retain all contributing value locations as their composite @@ -915,9 +940,11 @@ zero. ## Explicit imports -Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root -`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before -application decoding. Each sequence item is a mapping with exactly one `path`, +Imports are opt-in. With `Options.Imports` enabled, a document may use its root +`configkit` mapping for `imports` and `sequences` controls. Ordinary `Load` may use +`sequences` without enabling imports, but an `imports` field still requires +`Options.Imports`. ConfigKit removes recognized root controls before application +decoding. Each imports sequence item is a mapping with exactly one `path`, `dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings; `glob` accepts one path pattern string. The optional `required` boolean controls whether an empty selection or a missing named path is an error. Its default is @@ -927,8 +954,8 @@ strings. Every destination string is one literal mapping key and follows the input-destination validation rules above; quote numeric-looking keys so YAML reads them as strings. Null, scalar destinations, and non-string members are `import.invalid`, as are all other unsupported shapes and fields. A `configkit` -key below the root is invalid. With imports disabled, any occurrence remains -`import.disabled`. +key below the root is invalid. With imports disabled, root imports and nested +`configkit` keys remain `import.disabled`. The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0. There is no legacy alias: `_configkit` is ordinary application data, subject to diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b6b48db..835777a 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–12 complete. +Status: implementation in progress; Stages 1–13 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -493,6 +493,10 @@ selected-overlay policy surface until Stage 14. Validation: ordinary-load, import, composition, and metadata tests plus the everyday check. Include one public ordinary-load declaration example test. +**Completion:** Implemented and verified with ordinary-load, import, composition, +metadata, sequence-item, and source-version tests, the public ordinary-load +example, and `./scripts/check.sh`. + ## Stage 14 — Activate Selected Overlay Collection Policies Scope: ordinary loading's selected overlay and shared precedence integration, diff --git a/example_test.go b/example_test.go index abe843a..b296084 100644 --- a/example_test.go +++ b/example_test.go @@ -19,6 +19,28 @@ func ExampleLoad() { // Output: {true 3} } +func ExampleLoad_sequenceDeclaration() { + type Item struct { + ID string `yaml:"id"` + } + type Config struct { + Items []Item `yaml:"items"` + } + result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`configkit: + sequences: + - destination: [items] + key: id +items: + - id: prompt +`))) + if err != nil { + fmt.Println(err) + return + } + fmt.Println(result.Config.Items[0].ID) + // Output: prompt +} + func ExampleNoSource() { type Config struct { Workers int `yaml:"workers"` diff --git a/internal/engine/directives.go b/internal/engine/directives.go index df34ee7..71cd408 100644 --- a/internal/engine/directives.go +++ b/internal/engine/directives.go @@ -264,3 +264,72 @@ func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { } return diagnostics } + +func disabledRootImportDiagnostics(node *document.Node) []Diagnostic { + if node == nil || node.Kind != document.Mapping { + return nil + } + var rootDirective *document.Node + var rootLocation document.Location + var foundRootDirective bool + for index := range node.Mapping { + if node.Mapping[index].Key == directiveKey { + rootDirective = node.Mapping[index].Value + rootLocation = node.Mapping[index].KeyLocation + foundRootDirective = true + break + } + } + if foundRootDirective { + if rootDirective == nil || rootDirective.Kind != document.Mapping { + return disabledDirectiveDiagnostic(rootLocation) + } + for _, directive := range rootDirective.Mapping { + if directive.Key == "imports" { + return disabledDirectiveDiagnostic(rootLocation) + } + } + } + for _, entry := range node.Mapping { + if entry.Key == directiveKey { + continue + } + if diagnostic := disabledNestedDirectiveDiagnostics(entry.Value, Path{}.Key(entry.Key)); len(diagnostic) != 0 { + return diagnostic + } + } + return nil +} + +func disabledNestedDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { + if node.Kind == document.Mapping { + for _, entry := range node.Mapping { + childPath := path.Key(entry.Key) + if entry.Key == directiveKey { + location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column} + return []Diagnostic{{ + Code: CodeImportDisabled, Severity: SeverityError, Path: childPath, + Message: "configuration directives require imports to be enabled", Primary: &location, + }} + } + if diagnostics := disabledNestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { + return diagnostics + } + } + } else if node.Kind == document.Sequence { + for index, child := range node.Sequence { + if diagnostics := disabledNestedDirectiveDiagnostics(child, path.Index(index)); len(diagnostics) != 0 { + return diagnostics + } + } + } + return nil +} + +func disabledDirectiveDiagnostic(location document.Location) []Diagnostic { + public := Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column} + return []Diagnostic{{ + Code: CodeImportDisabled, Severity: SeverityError, Path: Keys(directiveKey), + Message: "configuration directives require imports to be enabled", Primary: &public, + }} +} diff --git a/internal/engine/imports.go b/internal/engine/imports.go index 0e351c1..b51adcc 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -46,12 +46,13 @@ type importAccounting struct { } type importTraversal struct { - access *filesystemAccess - accounting *importAccounting - limits importLimits - rule *VersionRule - versionState *sourceVersionState - defaults importDefaults + access *filesystemAccess + accounting *importAccounting + limits importLimits + rule *VersionRule + versionState *sourceVersionState + directiveContext rootDirectiveContext + defaults importDefaults scope acquisitionScope itemScope bool @@ -166,12 +167,17 @@ func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystem if versionState != nil { defaults = snapshotImportDefaults(versionState.importDefaults) } + directiveContext := rootDirectiveContextImportsOnly + if processing != nil && processing.directiveContext != 0 { + directiveContext = processing.directiveContext + } state := importTraversal{ access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState, - defaults: defaults, - scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()}, - sources: []SourceInfo{mainInfo}, - active: make(map[resourceIdentity]int), visited: visited, + directiveContext: directiveContext, + defaults: defaults, + scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()}, + sources: []SourceInfo{mainInfo}, + active: make(map[resourceIdentity]int), visited: visited, importGraph: newImportInclusionGraph(), } if mainIsItem { @@ -231,6 +237,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystem source: cloneSourceInfo(mainInfo), payload: body, document: placed, identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(), processing: processing, + sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations), }) diagnostics = state.walkDeclarations(basePath, declarations, 0, destination) } @@ -627,23 +634,34 @@ func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec impo var sequenceDeclarations []sequenceDeclarationSpec var diagnostics []Diagnostic var processing *sourceVersionProcessingResult + directiveContext := s.directiveContext + if directiveContext == 0 { + directiveContext = rootDirectiveContextImportsOnly + } if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault { versionDestination := destination if s.itemScope { versionDestination = appendDestination(s.itemPrefix.Index(0), destination) } - processed, failures := s.versionState.process(root, sourceVersionContribution, versionDestination, sourceID, policy.placement) + processed, failures := s.versionState.processWithDirectiveContext(root, sourceVersionContribution, versionDestination, sourceID, policy.placement, directiveContext) if len(failures) != 0 { return failures } processing = &processed body, declarations = processed.payload, processed.imports } else { - body, declarations, diagnostics = splitImportDirectives(root) - if len(diagnostics) != 0 { - return diagnostics + versionDestination := destination + if s.itemScope { + versionDestination = appendDestination(s.itemPrefix.Index(0), destination) } + processed := processSourceVersionWithDirectiveContext(root, s.rule, sourceVersionContribution, versionDestination, sourceVersionModeDefault, directiveContext) + if len(processed.diagnostics) != 0 { + return processed.diagnostics + } + processing = &processed + body, declarations = processed.payload, processed.imports } + sequenceDeclarations = cloneSequenceDeclarations(processing.sequences) placementDestination := destination mode := placementModeMapping if policy.placement == placementModeSequenceItem { diff --git a/internal/engine/load.go b/internal/engine/load.go index b0c3cf0..9d96d25 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -35,6 +35,7 @@ type Options[T any] struct { placementMode placementMode sequencePolicy sequencePolicy deferAssembly bool + directiveContext rootDirectiveContext importDefaultsValidated bool } @@ -174,6 +175,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo mainOptions.Overrides = request.overrides if request.ordinary { mainOptions.Overlay = request.overlay + mainOptions.directiveContext = rootDirectiveContextSource } var schema *schemaDescriptor diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 90c269c..78bd041 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -123,13 +123,43 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d } return input, diagnostics } + directiveContext := options.directiveContext + if directiveContext == 0 { + directiveContext = rootDirectiveContextImportsOnly + } if !options.Imports && parsed.document != nil { - if diagnostics := disabledDirectiveDiagnostics(parsed.document, Path{}); len(diagnostics) != 0 { - return input, diagnostics + var directiveDiagnostics []Diagnostic + if directiveContext == rootDirectiveContextSource { + directiveDiagnostics = disabledRootImportDiagnostics(parsed.document) + } else { + directiveDiagnostics = disabledDirectiveDiagnostics(parsed.document, Path{}) + } + if len(directiveDiagnostics) != 0 { + return input, directiveDiagnostics } } var versionProcessing *sourceVersionProcessingResult - if versionState != nil && versionState.mode != sourceVersionModeDefault { + if directiveContext == rootDirectiveContextSource { + var sourceID string + if parsed.source != nil { + sourceID = parsed.source.ID + } + var processed sourceVersionProcessingResult + var failures []Diagnostic + if versionState != nil { + processed, failures = versionState.processWithDirectiveContext(parsed.document, role, destination, sourceID, options.placementMode, directiveContext) + } else { + processed = processSourceVersionWithDirectiveContext(parsed.document, options.Definition.Version, role, destination, sourceVersionModeDefault, directiveContext) + failures = processed.diagnostics + } + if len(failures) != 0 { + return input, failures + } + versionProcessing = &processed + if role == sourceVersionMain && versionState != nil { + versionState.mainProcessing = &processed + } + } else if versionState != nil && versionState.mode != sourceVersionModeDefault { var sourceID string if parsed.source != nil { sourceID = parsed.source.ID @@ -175,6 +205,10 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d localPayload := input.document input.document = placed identity := importKeyForMainSource(parsed.source) + var sequenceDeclarations []sequenceDeclarationSpec + if versionProcessing != nil { + sequenceDeclarations = cloneSequenceDeclarations(versionProcessing.sequences) + } input.acquisition = []acquisitionScope{{ owner: identity, destination: destination.clone(), @@ -182,6 +216,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d source: cloneSourceInfo(*parsed.source), payload: localPayload, identity: identity, role: importRoleMain, scopeOwner: identity, document: placed, destination: destination.clone(), processing: versionProcessing, + sequenceDeclarations: sequenceDeclarations, }}, }} rawNodes := countInputNodes(parsed.document) @@ -273,6 +308,18 @@ func compositionForInputWithPolicy(input assembledInput, policy sequencePolicy) } return input.composition, nil } + if len(input.acquisition) != 0 { + acquired := &composedDocument{acquisition: cloneAcquisitionScopes(input.acquisition)} + if input.accounting != nil { + acquired.inputBytes = input.accounting.bytes + acquired.inputNodes = input.accounting.nodes + } else { + acquired.inputBytes = input.parsed.bytes + acquired.inputNodes = countInputNodes(input.parsed.document) + } + input.composition = acquired + return composeAcquiredInputWithPolicy(input, policy) + } if input.document == nil { return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil } diff --git a/internal/engine/ordinary_sequence_test.go b/internal/engine/ordinary_sequence_test.go new file mode 100644 index 0000000..3f3b7be --- /dev/null +++ b/internal/engine/ordinary_sequence_test.go @@ -0,0 +1,210 @@ +package engine + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +type ordinarySequenceItem struct { + ID string `yaml:"id"` +} + +type ordinarySequenceRecord struct { + ID string `yaml:"id"` + Children []ordinarySequenceItem `yaml:"children"` +} + +type ordinarySequenceConfig struct { + Items []ordinarySequenceItem `yaml:"items"` + Catalog struct { + Items []ordinarySequenceItem `yaml:"items"` + } `yaml:"catalog"` + Records []ordinarySequenceRecord `yaml:"records"` +} + +func TestOrdinaryLoadAcceptsDeclarationsWithoutImports(t *testing.T) { + result, err := testLoad[ordinarySequenceConfig](Bytes("catalog.yml", []byte(`configkit: + sequences: + - destination: [items] + key: id +items: + - id: prompt +`))) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result.Config.Items, []ordinarySequenceItem{{ID: "prompt"}}) { + t.Fatalf("items = %#v", result.Config.Items) + } + if _, ok := result.Metadata().Lookup(Keys("configkit")); ok { + t.Fatal("collection controls reached application metadata") + } + items, ok := result.Metadata().Lookup(Keys("items")) + if !ok || !items.Explicit || len(items.Origin.Locations) != 1 { + t.Fatalf("items metadata = %#v, %v", items, ok) + } +} + +func TestOrdinaryLoadKeepsDisabledImportRejection(t *testing.T) { + _, err := testLoad[ordinarySequenceConfig](Bytes("disabled.yml", []byte(`configkit: + imports: [{path: fragment.yml}] + sequences: [{destination: [items], key: id}] +`))) + if err == nil { + t.Fatal("source with disabled imports succeeded") + } + var loadError *LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportDisabled { + t.Fatalf("error = %#v", err) + } +} + +func TestOrdinaryLoadValidatesSingletonCollectionIdentity(t *testing.T) { + _, err := testLoad[ordinarySequenceConfig](Bytes("missing-identity.yml", []byte(`configkit: + sequences: + - destination: [items] + key: id +items: [{}] +`))) + if err == nil { + t.Fatal("singleton sequence without identity succeeded") + } + var loadError *LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeComposeInvalid { + t.Fatalf("error = %#v", err) + } +} + +func TestOrdinaryLoadCombinesImportedCollectionPeers(t *testing.T) { + tests := []struct { + name string + second string + want []ordinarySequenceItem + wantCode Code + }{ + {name: "distinct identities", second: "items: [{id: second}]\n", want: []ordinarySequenceItem{{ID: "first"}, {ID: "second"}}}, + {name: "duplicate peer identity", second: "items: [{id: first}]\n", wantCode: CodeComposeConflict}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - path: first.yml + - path: second.yml + sequences: + - destination: [items] + key: id + mode: append +`) + writeImportFile(t, directory, "first.yml", "items: [{id: first}]\n") + writeImportFile(t, directory, "second.yml", test.second) + + result, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Imports: true}) + if test.wantCode != "" { + if result != nil || err == nil { + t.Fatalf("result, err = %#v, %v", result, err) + } + var loadError *LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.wantCode { + t.Fatalf("error = %#v", err) + } + return + } + if err != nil { + var loadError *LoadError + if errors.As(err, &loadError) { + t.Fatalf("diagnostics = %#v", loadError.Diagnostics()) + } + t.Fatal(err) + } + if !reflect.DeepEqual(result.Config.Items, test.want) { + t.Fatalf("items = %#v, want %#v", result.Config.Items, test.want) + } + inline, err := testLoad[ordinarySequenceConfig](Bytes("inline.yml", []byte(`configkit: + sequences: + - destination: [items] + key: id + mode: append +items: [{id: first}, {id: second}] +`))) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result.Config.Items, inline.Config.Items) { + t.Fatalf("imported items %#v differ from inline items %#v", result.Config.Items, inline.Config.Items) + } + }) + } +} + +func TestImportedCollectionDeclarationsUseRelativePlacement(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - path: first.yml + destination: [catalog] + - path: second.yml + destination: [catalog] +`) + fragment := `configkit: + sequences: + - destination: [items] + key: id + mode: append +items: [{id: ITEM}] +` + writeImportFile(t, directory, "first.yml", strings.ReplaceAll(fragment, "ITEM", "first")) + writeImportFile(t, directory, "second.yml", strings.ReplaceAll(fragment, "ITEM", "second")) + + result, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Imports: true}) + if err != nil { + t.Fatal(err) + } + want := []ordinarySequenceItem{{ID: "first"}, {ID: "second"}} + if !reflect.DeepEqual(result.Config.Catalog.Items, want) { + t.Fatalf("catalog items = %#v, want %#v", result.Config.Catalog.Items, want) + } +} + +func TestSequenceItemSourceAppliesItsCollectionDeclarations(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - path: item.yml + destination: [records] + placement: sequence-item +`) + writeImportFile(t, directory, "item.yml", `configkit: + sequences: + - destination: [children] + key: id + mode: append +id: parent +children: [{id: child}] +`) + result, err := testLoad[ordinarySequenceConfig](File(mainPath), Options[ordinarySequenceConfig]{Imports: true}) + if err != nil { + t.Fatal(err) + } + want := []ordinarySequenceRecord{{ID: "parent", Children: []ordinarySequenceItem{{ID: "child"}}}} + if !reflect.DeepEqual(result.Config.Records, want) { + t.Fatalf("records = %#v, want %#v", result.Config.Records, want) + } +} + +func TestOrdinaryLoadAllowsDeclarationsWithoutValues(t *testing.T) { + result, err := testLoad[ordinarySequenceConfig](Bytes("policy.yml", []byte(`configkit: + sequences: + - destination: [items] + key: id +`))) + if err != nil { + t.Fatal(err) + } + if result.Config.Items != nil { + t.Fatalf("items = %#v, want absent", result.Config.Items) + } +} diff --git a/internal/engine/source_version.go b/internal/engine/source_version.go index c8a0e27..aae1abb 100644 --- a/internal/engine/source_version.go +++ b/internal/engine/source_version.go @@ -118,11 +118,18 @@ func (state *sourceVersionState) process(root *document.Node, role sourceVersion if state == nil || state.mode == sourceVersionModeDefault { return sourceVersionProcessingResult{}, nil } + return state.processWithDirectiveContext(root, role, destination, sourceID, placement, rootDirectiveContextImportsOnly) +} + +func (state *sourceVersionState) processWithDirectiveContext(root *document.Node, role sourceVersionRole, destination Path, sourceID string, placement placementMode, directiveContext rootDirectiveContext) (sourceVersionProcessingResult, []Diagnostic) { + if state == nil { + return sourceVersionProcessingResult{}, nil + } projectedDestination := destination.clone() if placement == placementModeSequenceItem { projectedDestination = projectedDestination.Index(0) } - result := processSourceVersion(root, state.localRule, role, projectedDestination, state.mode) + result := processSourceVersionWithDirectiveContext(root, state.localRule, role, projectedDestination, state.mode, directiveContext) if result.evidence != nil { result.evidence.sourceID = sourceID state.evidence = append(state.evidence, result.evidence) @@ -139,11 +146,12 @@ func (state *sourceVersionState) process(root *document.Node, role sourceVersion } type sourceVersionProcessingResult struct { - payload *document.Node - imports []importDeclarationSpec - sequences []sequenceDeclarationSpec - evidence *sourceVersionEvidence - diagnostics []Diagnostic + payload *document.Node + imports []importDeclarationSpec + sequences []sequenceDeclarationSpec + directiveContext rootDirectiveContext + evidence *sourceVersionEvidence + diagnostics []Diagnostic } func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule { @@ -214,7 +222,7 @@ func processSourceVersionWithDirectiveContext(root *document.Node, rule *Version return sourceVersionProcessingResult{diagnostics: diagnostics} } result := sourceVersionProcessingResult{ - payload: extracted.body, imports: extracted.imports, + payload: extracted.body, imports: extracted.imports, directiveContext: directiveContext, sequences: cloneSequenceDeclarations(extracted.sequences), } if mode == sourceVersionModeDefault {