Activate ordinary source collection declarations

This commit is contained in:
2026-10-23 19:12:39 +00:00
parent dfeaf90320
commit 9a7027a04c
9 changed files with 448 additions and 41 deletions

View File

@@ -52,7 +52,8 @@ preserves already validated secret state without rereading the environment.
The current lifecycle is: The current lifecycle is:
```text ```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 → build schema → check the optional application version → apply a selected overlay
→ apply explicit CLI overrides → expand sibling inheritance → apply explicit CLI overrides → expand sibling inheritance
→ create and clone defaults → strictly decode → 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 at the destination; `sequence-item` explicitly wraps the mapping as one item at a
non-root destination. Unknown modes, invalid destinations, and invalid or non-root destination. Unknown modes, invalid destinations, and invalid or
conflicting sequence registrations fail before source acquisition. Overlay and 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 imports, active overlays, and filesystem context as invalid combinations but
accepts overrides. Input containing a reserved `configkit` mapping key is rejected accepts overrides and remains an absent source rather than an empty document.
while imports are disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation 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 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 ## Strict document composition
ConfigKit strictly composes parsed base documents when `Options.Imports` is true. Ordinary `Load` applies strict document composition to its parsed source scope;
Application defaults do not participate in this operation. `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 | | 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` | | 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 Every non-mapping overlap is a conflict, even when values are equal, except for
sequence/sequence contributions at a registered sequence-composition path. Nulls sequence/sequence contributions at a registered sequence-composition path or a
remain atomic conflicts. Unregistered sequences conflict when repeated and are source-declared collection destination. Nulls remain atomic conflicts.
never joined by index. Disjoint entries under shared mappings compose, making Unregistered and undeclared sequences conflict when repeated and are never joined
stable keyed mappings the natural representation for independently contributed by index. Disjoint entries under shared mappings compose, making stable keyed
entries. mappings the natural representation for independently contributed entries.
`Definition.SequenceCompositions` registers literal mapping paths and one literal `Definition.SequenceCompositions` registers literal mapping paths and one literal
`IdentityField` for sequences that should compose independently authored records. `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 record identity. Final typed decoding and application validation still run on the
completed configuration. completed configuration.
Ordinary source documents may also declare collection behavior under the root
`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 contributes no child definitions. Two empty mappings compose, and
an empty mapping neither clears nor conflicts with another mapping's children. an empty mapping neither clears nor conflicts with another mapping's children.
Shared mapping containers retain all contributing value locations as their composite Shared mapping containers retain all contributing value locations as their composite
@@ -915,9 +940,11 @@ zero.
## Explicit imports ## Explicit imports
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root Imports are opt-in. With `Options.Imports` enabled, a document may use its root
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before `configkit` mapping for `imports` and `sequences` controls. Ordinary `Load` may use
application decoding. Each sequence item is a mapping with exactly one `path`, `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; `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 `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 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 input-destination validation rules above; quote numeric-looking keys so YAML
reads them as strings. Null, scalar destinations, and non-string members are reads them as strings. Null, scalar destinations, and non-string members are
`import.invalid`, as are all other unsupported shapes and fields. A `configkit` `import.invalid`, as are all other unsupported shapes and fields. A `configkit`
key below the root is invalid. With imports disabled, any occurrence remains key below the root is invalid. With imports disabled, root imports and nested
`import.disabled`. `configkit` keys remain `import.disabled`.
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0. 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 There is no legacy alias: `_configkit` is ordinary application data, subject to

View File

@@ -1,6 +1,6 @@
# Sequence Identity And Combination Implementation Plan # 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 Audience: a Luna coding agent implementing one numbered stage per prompt, in
order. This plan starts at commit 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 Validation: ordinary-load, import, composition, and metadata tests plus
the everyday check. Include one public ordinary-load declaration example test. 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 ## Stage 14 — Activate Selected Overlay Collection Policies
Scope: ordinary loading's selected overlay and shared precedence integration, Scope: ordinary loading's selected overlay and shared precedence integration,

View File

@@ -19,6 +19,28 @@ func ExampleLoad() {
// Output: {true 3} <nil> // Output: {true 3} <nil>
} }
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() { func ExampleNoSource() {
type Config struct { type Config struct {
Workers int `yaml:"workers"` Workers int `yaml:"workers"`

View File

@@ -264,3 +264,72 @@ func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
} }
return diagnostics 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,
}}
}

View File

@@ -46,12 +46,13 @@ type importAccounting struct {
} }
type importTraversal struct { type importTraversal struct {
access *filesystemAccess access *filesystemAccess
accounting *importAccounting accounting *importAccounting
limits importLimits limits importLimits
rule *VersionRule rule *VersionRule
versionState *sourceVersionState versionState *sourceVersionState
defaults importDefaults directiveContext rootDirectiveContext
defaults importDefaults
scope acquisitionScope scope acquisitionScope
itemScope bool itemScope bool
@@ -166,12 +167,17 @@ func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystem
if versionState != nil { if versionState != nil {
defaults = snapshotImportDefaults(versionState.importDefaults) defaults = snapshotImportDefaults(versionState.importDefaults)
} }
directiveContext := rootDirectiveContextImportsOnly
if processing != nil && processing.directiveContext != 0 {
directiveContext = processing.directiveContext
}
state := importTraversal{ state := importTraversal{
access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState, access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
defaults: defaults, directiveContext: directiveContext,
scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()}, defaults: defaults,
sources: []SourceInfo{mainInfo}, scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()},
active: make(map[resourceIdentity]int), visited: visited, sources: []SourceInfo{mainInfo},
active: make(map[resourceIdentity]int), visited: visited,
importGraph: newImportInclusionGraph(), importGraph: newImportInclusionGraph(),
} }
if mainIsItem { if mainIsItem {
@@ -231,6 +237,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystem
source: cloneSourceInfo(mainInfo), payload: body, document: placed, source: cloneSourceInfo(mainInfo), payload: body, document: placed,
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey, identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
destination: destination.clone(), processing: processing, destination: destination.clone(), processing: processing,
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
}) })
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination) diagnostics = state.walkDeclarations(basePath, declarations, 0, destination)
} }
@@ -627,23 +634,34 @@ func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec impo
var sequenceDeclarations []sequenceDeclarationSpec var sequenceDeclarations []sequenceDeclarationSpec
var diagnostics []Diagnostic var diagnostics []Diagnostic
var processing *sourceVersionProcessingResult var processing *sourceVersionProcessingResult
directiveContext := s.directiveContext
if directiveContext == 0 {
directiveContext = rootDirectiveContextImportsOnly
}
if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault { if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault {
versionDestination := destination versionDestination := destination
if s.itemScope { if s.itemScope {
versionDestination = appendDestination(s.itemPrefix.Index(0), destination) 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 { if len(failures) != 0 {
return failures return failures
} }
processing = &processed processing = &processed
body, declarations = processed.payload, processed.imports body, declarations = processed.payload, processed.imports
} else { } else {
body, declarations, diagnostics = splitImportDirectives(root) versionDestination := destination
if len(diagnostics) != 0 { if s.itemScope {
return diagnostics 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 placementDestination := destination
mode := placementModeMapping mode := placementModeMapping
if policy.placement == placementModeSequenceItem { if policy.placement == placementModeSequenceItem {

View File

@@ -35,6 +35,7 @@ type Options[T any] struct {
placementMode placementMode placementMode placementMode
sequencePolicy sequencePolicy sequencePolicy sequencePolicy
deferAssembly bool deferAssembly bool
directiveContext rootDirectiveContext
importDefaultsValidated bool importDefaultsValidated bool
} }
@@ -174,6 +175,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
mainOptions.Overrides = request.overrides mainOptions.Overrides = request.overrides
if request.ordinary { if request.ordinary {
mainOptions.Overlay = request.overlay mainOptions.Overlay = request.overlay
mainOptions.directiveContext = rootDirectiveContextSource
} }
var schema *schemaDescriptor var schema *schemaDescriptor

View File

@@ -123,13 +123,43 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
} }
return input, diagnostics return input, diagnostics
} }
directiveContext := options.directiveContext
if directiveContext == 0 {
directiveContext = rootDirectiveContextImportsOnly
}
if !options.Imports && parsed.document != nil { if !options.Imports && parsed.document != nil {
if diagnostics := disabledDirectiveDiagnostics(parsed.document, Path{}); len(diagnostics) != 0 { var directiveDiagnostics []Diagnostic
return input, diagnostics if directiveContext == rootDirectiveContextSource {
directiveDiagnostics = disabledRootImportDiagnostics(parsed.document)
} else {
directiveDiagnostics = disabledDirectiveDiagnostics(parsed.document, Path{})
}
if len(directiveDiagnostics) != 0 {
return input, directiveDiagnostics
} }
} }
var versionProcessing *sourceVersionProcessingResult 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 var sourceID string
if parsed.source != nil { if parsed.source != nil {
sourceID = parsed.source.ID sourceID = parsed.source.ID
@@ -175,6 +205,10 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
localPayload := input.document localPayload := input.document
input.document = placed input.document = placed
identity := importKeyForMainSource(parsed.source) identity := importKeyForMainSource(parsed.source)
var sequenceDeclarations []sequenceDeclarationSpec
if versionProcessing != nil {
sequenceDeclarations = cloneSequenceDeclarations(versionProcessing.sequences)
}
input.acquisition = []acquisitionScope{{ input.acquisition = []acquisitionScope{{
owner: identity, owner: identity,
destination: destination.clone(), destination: destination.clone(),
@@ -182,6 +216,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
source: cloneSourceInfo(*parsed.source), payload: localPayload, source: cloneSourceInfo(*parsed.source), payload: localPayload,
identity: identity, role: importRoleMain, scopeOwner: identity, identity: identity, role: importRoleMain, scopeOwner: identity,
document: placed, destination: destination.clone(), processing: versionProcessing, document: placed, destination: destination.clone(), processing: versionProcessing,
sequenceDeclarations: sequenceDeclarations,
}}, }},
}} }}
rawNodes := countInputNodes(parsed.document) rawNodes := countInputNodes(parsed.document)
@@ -273,6 +308,18 @@ func compositionForInputWithPolicy(input assembledInput, policy sequencePolicy)
} }
return input.composition, nil 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 { if input.document == nil {
return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil
} }

View File

@@ -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)
}
}

View File

@@ -118,11 +118,18 @@ func (state *sourceVersionState) process(root *document.Node, role sourceVersion
if state == nil || state.mode == sourceVersionModeDefault { if state == nil || state.mode == sourceVersionModeDefault {
return sourceVersionProcessingResult{}, nil 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() projectedDestination := destination.clone()
if placement == placementModeSequenceItem { if placement == placementModeSequenceItem {
projectedDestination = projectedDestination.Index(0) 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 { if result.evidence != nil {
result.evidence.sourceID = sourceID result.evidence.sourceID = sourceID
state.evidence = append(state.evidence, result.evidence) state.evidence = append(state.evidence, result.evidence)
@@ -139,11 +146,12 @@ func (state *sourceVersionState) process(root *document.Node, role sourceVersion
} }
type sourceVersionProcessingResult struct { type sourceVersionProcessingResult struct {
payload *document.Node payload *document.Node
imports []importDeclarationSpec imports []importDeclarationSpec
sequences []sequenceDeclarationSpec sequences []sequenceDeclarationSpec
evidence *sourceVersionEvidence directiveContext rootDirectiveContext
diagnostics []Diagnostic evidence *sourceVersionEvidence
diagnostics []Diagnostic
} }
func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule { func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule {
@@ -214,7 +222,7 @@ func processSourceVersionWithDirectiveContext(root *document.Node, rule *Version
return sourceVersionProcessingResult{diagnostics: diagnostics} return sourceVersionProcessingResult{diagnostics: diagnostics}
} }
result := sourceVersionProcessingResult{ result := sourceVersionProcessingResult{
payload: extracted.body, imports: extracted.imports, payload: extracted.body, imports: extracted.imports, directiveContext: directiveContext,
sequences: cloneSequenceDeclarations(extracted.sequences), sequences: cloneSequenceDeclarations(extracted.sequences),
} }
if mode == sourceVersionModeDefault { if mode == sourceVersionModeDefault {