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

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

View File

@@ -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 {

View File

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

View File

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

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 {
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 {