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:
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user