Activate ordinary source collection declarations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,28 @@ func ExampleLoad() {
|
||||
// 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() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
210
internal/engine/ordinary_sequence_test.go
Normal file
210
internal/engine/ordinary_sequence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user