Schedule underlay groups as dependency forests

This commit is contained in:
2026-10-19 19:53:01 +00:00
parent 263dd2f1bd
commit 014b99504b
7 changed files with 437 additions and 34 deletions

View File

@@ -941,6 +941,9 @@ own strict mapping scope, with helpers before the bodies that import them. Those
local scopes do not use application collection policies and are not flattened into
the outer schedule. Item positions and diagnostic paths follow the resulting
assembly order.
Each underlay group likewise acquires its inputs before dependency-ready assembly
in one strict scope. Independent groups remain separate precedence tiers; a higher
tier still replaces a lower tier's sequence values wholesale.
Assembly order never permits replacement. Required missing paths fail; `optional:
true` suppresses only target nonexistence, not invalid paths, containment failures,
directories, permissions, parse errors, or composition errors.

View File

@@ -89,6 +89,15 @@ func (graph importInclusionGraph) clone() importInclusionGraph {
return cloned
}
func mergeImportInclusionGraph(target *importInclusionGraph, source importInclusionGraph) {
for _, node := range source.nodes {
target.addNode(node)
}
for _, edge := range source.edges {
target.addEdge(edge.importer, edge.imported, edge.location)
}
}
func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Location) (Diagnostic, bool) {
keys := make([]dependencyKey, len(graph.nodes))
for index, node := range graph.nodes {
@@ -149,21 +158,41 @@ func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Locati
}
func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph) acquisitionScope {
positions := make(map[importNodeKey]int, len(scope.contributions))
slots := make([]int, 0, len(scope.contributions))
keys := make([]dependencyKey, 0, len(scope.contributions))
for index, contribution := range scope.contributions {
if contribution.scopeOwner != scope.owner {
continue
}
positions[contribution.identity] = len(keys)
slots = append(slots, index)
keys = append(keys, contribution.identity.dependencyKey())
}
if len(keys) < 2 {
return scope
}
scheduled := scope
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, graph)
return scheduled
}
func scheduleAcquisitionGroup(scopes []acquisitionScope, graph importInclusionGraph) acquisitionScope {
var contributions []acquiredContribution
for _, scope := range scopes {
contributions = append(contributions, scope.contributions...)
}
slots := make([]int, len(contributions))
for index := range slots {
slots[index] = index
}
return acquisitionScope{contributions: scheduleContributionSlots(contributions, slots, graph)}
}
func scheduleContributionSlots(contributions []acquiredContribution, slots []int, graph importInclusionGraph) []acquiredContribution {
if len(slots) < 2 {
return append([]acquiredContribution(nil), contributions...)
}
positions := make(map[importNodeKey]int, len(slots))
keys := make([]dependencyKey, len(slots))
for position, slot := range slots {
contribution := contributions[slot]
positions[contribution.identity] = position
keys[position] = contribution.identity.dependencyKey()
}
edges := make([]dependencyEdge, 0, len(graph.edges))
for _, edge := range graph.edges {
importer, importerOK := positions[edge.importer]
@@ -174,16 +203,86 @@ func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph
}
order, _ := scheduleDependencies(keys, edges)
if len(order) != len(keys) {
return scope
return append([]acquiredContribution(nil), contributions...)
}
scheduled := scope
scheduled.contributions = append([]acquiredContribution(nil), scope.contributions...)
scheduled := append([]acquiredContribution(nil), contributions...)
for position, scheduledIndex := range order {
scheduled.contributions[slots[position]] = scope.contributions[slots[scheduledIndex]]
scheduled[slots[position]] = contributions[slots[scheduledIndex]]
}
return scheduled
}
type importNamespaceAnchors struct {
kind string
host string
native string
input string
}
func explicitNamespaceAnchor(group, input int) string {
return fmt.Sprintf("%020d/%020d", group, input)
}
func reanchorImportKey(key importNodeKey, anchors importNamespaceAnchors) importNodeKey {
if anchors.kind != "" {
key.namespaceKind = anchors.kind
}
switch {
case key.resourceKind == "synthetic":
key.namespaceAnchor = anchors.input
case key.namespaceKind == "native":
key.namespaceAnchor = anchors.native
default:
key.namespaceAnchor = anchors.host
}
return key
}
func reanchorImportGraph(graph importInclusionGraph, anchors importNamespaceAnchors) (importInclusionGraph, map[importNodeKey]importNodeKey) {
reanchored := newImportInclusionGraph()
keys := make(map[importNodeKey]importNodeKey, len(graph.nodes))
for _, node := range graph.nodes {
old := node.key
node.key = reanchorImportKey(old, anchors)
keys[old] = node.key
reanchored.addNode(node)
}
for _, edge := range graph.edges {
reanchored.addEdge(keys[edge.importer], keys[edge.imported], edge.location)
}
return reanchored, keys
}
func reanchorAcquisitionScope(scope *acquisitionScope, keys map[importNodeKey]importNodeKey, anchors importNamespaceAnchors) {
if scope == nil {
return
}
if key, exists := keys[scope.owner]; exists {
scope.owner = key
} else {
scope.owner = reanchorImportKey(scope.owner, anchors)
}
for index := range scope.contributions {
contribution := &scope.contributions[index]
if key, exists := keys[contribution.identity]; exists {
contribution.identity = key
} else {
contribution.identity = reanchorImportKey(contribution.identity, anchors)
}
if key, exists := keys[contribution.scopeOwner]; exists {
contribution.scopeOwner = key
} else {
contribution.scopeOwner = reanchorImportKey(contribution.scopeOwner, anchors)
}
reanchorAcquisitionScope(contribution.pendingItem, keys, anchors)
if contribution.nested != nil {
for nestedIndex := range contribution.nested.acquisition {
reanchorAcquisitionScope(&contribution.nested.acquisition[nestedIndex], keys, anchors)
}
}
}
}
func importKeyFor(access *filesystemAccess, canonical string, synthetic bool) importNodeKey {
kind := "host"
resourceKind := "file"

View File

@@ -80,12 +80,16 @@ func loadImportedDocumentsTrackedWithVersion(source Source, context *FilesystemC
}
func loadImportedDocumentsTrackedWithProcessing(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedDocumentsTrackedWithProcessingDeferred(source, context, main, destination, rule, limits, accounting, visited, hostToken, versionState, processing, policy, false)
}
func loadImportedDocumentsTrackedWithProcessingDeferred(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy, deferAssembly bool) (*composedDocument, []SourceInfo, []Diagnostic) {
if nativeContext(source, context) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil {
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
}
return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy)
return loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy, deferAssembly)
}
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
@@ -100,7 +104,7 @@ func loadImportedDocumentsTrackedWithProcessing(source Source, context *Filesyst
if hostToken != nil {
access.token = hostToken
}
return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy)
return loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy, deferAssembly)
}
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
@@ -124,6 +128,10 @@ func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePa
}
func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy, false)
}
func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy, deferAssembly bool) (*composedDocument, []SourceInfo, []Diagnostic) {
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
_ = access.close()
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
@@ -221,8 +229,10 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
}
if len(diagnostics) == 0 {
state.scheduleAcquisitionScopes(&state.scope)
if !deferAssembly {
diagnostics = state.composePendingItems(&state.scope)
}
}
closeErr := access.close()
if len(diagnostics) != 0 {
@@ -234,6 +244,27 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
if closeErr != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
}
if deferAssembly {
acquisition := state.scope
if mainIsItem {
localScope := state.scope
acquisition = acquisitionScope{
owner: mainNodeKey, destination: destination.clone(),
contributions: []acquiredContribution{{
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(),
processing: processing, pendingItem: &localScope,
}},
}
}
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
return &composedDocument{
acquisition: []acquisitionScope{cloneAcquisitionScope(acquisition)},
importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes,
}, cloneSourceInfos(state.sources), nil
}
if mainIsItem {
localScope := state.scope
documents := make([]*document.Node, 0, len(localScope.contributions))
@@ -313,6 +344,29 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
return composed, cloneSourceInfos(state.sources), diagnostics
}
func reservePendingItemPlacements(scope *acquisitionScope, limits importLimits, accounting *importAccounting) *Diagnostic {
if scope == nil {
return nil
}
for index := range scope.contributions {
contribution := &scope.contributions[index]
if contribution.pendingItem == nil {
continue
}
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem)
if accounting.nodes+placementNodes > limits.nodes {
diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)
return &diagnostic
}
accounting.nodes += placementNodes
contribution.placementCharged = true
if diagnostic := reservePendingItemPlacements(contribution.pendingItem, limits, accounting); diagnostic != nil {
return diagnostic
}
}
return nil
}
func (s *importTraversal) checkInitialLimits() *Diagnostic {
if s.accounting.sources > s.limits.sources {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
@@ -609,6 +663,9 @@ func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnos
if len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
}
if diagnostic := importedVersionClaim(documents, *itemScope, completed, s.rule); diagnostic != nil {
return []Diagnostic{*diagnostic}
}
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)}
projectNestedEvidence(completed, *itemScope)
_, placed, err := placeDocumentWithMode(completed.root, contribution.destination, placementModeSequenceItem, contribution.payload.Location, maxDocumentDepth, maxDocumentNodes)
@@ -616,10 +673,12 @@ func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnos
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", contribution.source.ImportedFrom)}
}
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem)
if !contribution.placementCharged {
if s.accounting.nodes+placementNodes > s.limits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)}
}
s.accounting.nodes += placementNodes
}
contribution.payload = completed.root
contribution.document = placed
contribution.nested = completed

View File

@@ -33,6 +33,7 @@ type Options[T any] struct {
destination Path
placementMode placementMode
sequencePolicy sequencePolicy
deferAssembly bool
}
// FilesystemContext supplies filesystem roots for imports and overlays.

View File

@@ -33,6 +33,7 @@ type acquiredContribution struct {
processing *sourceVersionProcessingResult
nested *composedDocument
pendingItem *acquisitionScope
placementCharged bool
}
// acquisitionScope is the ordered strict mapping scope produced by one input
@@ -54,6 +55,7 @@ func cloneAcquisitionScope(scope acquisitionScope) acquisitionScope {
document: contribution.document, destination: contribution.destination.clone(),
relativeDestination: contribution.relativeDestination.clone(), processing: contribution.processing,
nested: cloneComposedDocument(contribution.nested), pendingItem: cloneAcquisitionScopePointer(contribution.pendingItem),
placementCharged: contribution.placementCharged,
}
}
}
@@ -135,12 +137,18 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
}
}
if options.Imports {
if options.deferAssembly {
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
} else {
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
}
if len(diagnostics) != 0 {
return input, diagnostics
}
if input.composition != nil {
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
input.document = input.composition.root
}
} else {
input.sources = sourceInfos(parsed.source)
if versionProcessing != nil {

View File

@@ -158,14 +158,23 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
initialBytes, initialNodes := accounting.bytes, accounting.nodes
visited := make(map[resourceIdentity]importRoute)
hostToken := &namespaceToken{}
nativeAnchors := make(map[*namespaceToken]string)
for inputIndex, contribution := range group.inputs {
if token := nativeTokenForLoadInput(contribution); token != nil {
if _, exists := nativeAnchors[token]; !exists {
nativeAnchors[token] = explicitNamespaceAnchor(index, inputIndex+1)
}
}
}
var scopes []acquisitionScope
inclusionGraph := newImportInclusionGraph()
var sources []SourceInfo
var contexts map[string]ResourceContext
for _, contribution := range group.inputs {
for inputIndex, contribution := range group.inputs {
options := Options[struct{}]{
Imports: contribution.imports, Filesystem: contribution.filesystem,
Definition: Definition[struct{}]{Version: rule}, destination: contribution.destination.clone(),
placementMode: contribution.placementMode, sequencePolicy: policy,
placementMode: contribution.placementMode, sequencePolicy: policy, deferAssembly: true,
}
acquired, diagnostics := assembleBaseInputTrackedForRole(contribution.source, options, contribution.destination, accounting, visited, hostToken, versionState, sourceVersionContribution)
for _, source := range acquired.sources {
@@ -177,17 +186,38 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
if len(diagnostics) != 0 {
return nil, sources, contexts, projectPriorInputDiagnosticPaths(diagnostics, scopes)
}
if acquired.document == nil {
if acquired.document == nil && len(acquired.acquisition) == 0 {
continue
}
scopes = append(scopes, cloneAcquisitionScopes(acquired.acquisition)...)
anchors := importNamespaceAnchors{
kind: "host", host: explicitNamespaceAnchor(index, 0),
input: explicitNamespaceAnchor(index, inputIndex+1),
}
if token := nativeTokenForLoadInput(contribution); token != nil {
anchors.kind = "native"
anchors.native = nativeAnchors[token]
}
inputScopes := cloneAcquisitionScopes(acquired.acquisition)
inputGraph := newImportInclusionGraph()
if acquired.composition != nil {
inputGraph = acquired.composition.importGraph
}
inputGraph, keyMap := reanchorImportGraph(inputGraph, anchors)
for scopeIndex := range inputScopes {
reanchorAcquisitionScope(&inputScopes[scopeIndex], keyMap, anchors)
}
scopes = append(scopes, inputScopes...)
mergeImportInclusionGraph(&inclusionGraph, inputGraph)
}
groupScope := scheduleAcquisitionGroup(scopes, inclusionGraph)
traversal := importTraversal{accounting: accounting, limits: defaultImportLimits(), rule: rule, importGraph: inclusionGraph}
if diagnostics := traversal.composePendingItems(&groupScope); len(diagnostics) != 0 {
return nil, sources, contexts, diagnostics
}
var documents []*document.Node
for _, scope := range scopes {
for _, contribution := range scope.contributions {
for _, contribution := range groupScope.contributions {
documents = append(documents, contribution.document)
}
}
if len(documents) == 0 {
return nil, sources, contexts, nil
}
@@ -197,6 +227,18 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
}
if composition != nil && rule != nil {
ranges := indexSequenceRanges(composition.sequenceRanges)
for sourceIndex, document := range documents {
if groupScope.contributions[sourceIndex].role == importRoleMain {
continue
}
if node := fragmentVersionClaimWithIndex(document, rule, sourceIndex, ranges); node != nil {
location := publicLocation(node.Location)
return nil, sources, contexts, []Diagnostic{{
Code: CodeImportVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "imported fragments cannot contribute to the schema-version path", Primary: &location,
}}
}
}
for sourceIndex, document := range documents {
if node := fragmentVersionClaimWithIndex(document, rule, sourceIndex, ranges); node != nil {
location := publicLocation(node.Location)
@@ -209,13 +251,14 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
}
composition.inputBytes = accounting.bytes - initialBytes
composition.inputNodes = accounting.nodes - initialNodes
composition.acquisition = cloneAcquisitionScopes(scopes)
var projectionScope acquisitionScope
for _, scope := range scopes {
projectionScope.contributions = append(projectionScope.contributions, scope.contributions...)
groupScope.owner = importNodeKey{namespaceKind: "group", namespaceAnchor: explicitNamespaceAnchor(index, 0), resourceKind: "scope"}
for contributionIndex := range groupScope.contributions {
groupScope.contributions[contributionIndex].scopeOwner = groupScope.owner
}
projectNestedEvidence(composition, projectionScope)
projectAcquiredDestinations(composition, projectionScope, sources)
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(groupScope)}
composition.importGraph = inclusionGraph.clone()
projectNestedEvidence(composition, groupScope)
projectAcquiredDestinations(composition, groupScope, sources)
for pointer, origin := range composition.origins {
origin.Layer = LayerUnderlay
composition.origins[pointer] = origin
@@ -225,3 +268,13 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
}
return composition, sources, contexts, nil
}
func nativeTokenForLoadInput(input loadInput) *namespaceToken {
if input.source.kind == sourceFilesystem && input.source.namespace != nil {
return input.source.namespace.token
}
if input.filesystem != nil && input.filesystem.FS != nil && input.filesystem.FS.namespace != nil {
return input.filesystem.FS.namespace.token
}
return nil
}

View File

@@ -19,6 +19,186 @@ func testGroupedLoad[T any](request loadRequest[T]) (*testResult[T], error) {
return testLoaded(loaded), err
}
func TestUnderlayGroupSchedulesInterleavedImportBranches(t *testing.T) {
directory := t.TempDir()
zRoot := writeImportFile(t, directory, "z-root.yml", `configkit:
imports:
- path: d-parent.yml
items: [{id: z}]
`)
dParent := writeImportFile(t, directory, "d-parent.yml", `configkit:
imports:
- path: a-dependency.yml
items: [{id: d}]
`)
aDependency := writeImportFile(t, directory, "a-dependency.yml", "items: [{id: a}]\n")
bRoot := writeImportFile(t, directory, "b-root.yml", `configkit:
imports:
- path: c-helper.yml
items: [{id: b}]
`)
cHelper := writeImportFile(t, directory, "c-helper.yml", "items: [{id: c}]\n")
request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{
{source: NoSource()},
{source: File(zRoot), imports: true},
{source: File(bRoot), imports: true},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
}
result, err := testGroupedLoad(request)
if err != nil {
t.Fatalf("grouped load: %v", err)
}
items := result.Config["items"].([]any)
wantIDs := []string{"a", "c", "b", "d", "z"}
if len(items) != len(wantIDs) {
t.Fatalf("scheduled items = %#v", items)
}
for index, want := range wantIDs {
if got := items[index].(map[string]any)["id"]; got != want {
t.Errorf("scheduled item %d ID = %#v, want %q", index, got, want)
}
}
wantPaths := []string{zRoot, dParent, aDependency, bRoot, cHelper}
for index, want := range wantPaths {
if got := result.Sources()[index].Path; got != canonicalTestPath(t, want) {
t.Errorf("acquired source %d path = %q, want %q", index, got, canonicalTestPath(t, want))
}
}
if got := result.Sources(); len(got) != len(wantPaths) {
t.Fatalf("NoSource added a source record: %#v", got)
}
}
func TestUnderlayNamespaceAnchorsUseInputPositionsAndFilesystemIdentity(t *testing.T) {
firstNamespace := NewFilesystem("same-label", fstest.MapFS{
"root.yml": &fstest.MapFile{Data: []byte("items: [{id: first}]\n")},
"later.yml": &fstest.MapFile{Data: []byte("items: [{id: copied}]\n")},
})
secondNamespace := NewFilesystem("same-label", fstest.MapFS{
"root.yml": &fstest.MapFile{Data: []byte("items: [{id: second}]\n")},
})
request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{
{source: firstNamespace.File("root.yml")},
{source: secondNamespace.File("root.yml")},
{source: firstNamespace.File("later.yml")},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
}
result, err := testGroupedLoad(request)
if err != nil {
t.Fatalf("grouped load with native namespaces: %v", err)
}
items := result.Config["items"].([]any)
wantIDs := []string{"copied", "first", "second"}
if len(items) != len(wantIDs) {
t.Fatalf("scheduled native items = %#v", items)
}
for index, want := range wantIDs {
if got := items[index].(map[string]any)["id"]; got != want {
t.Errorf("scheduled native item %d ID = %#v, want %q", index, got, want)
}
}
if sources := result.Sources(); len(sources) != len(wantIDs) {
t.Fatalf("source records = %#v", sources)
}
}
func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) {
directory := t.TempDir()
zItem := writeImportFile(t, directory, "z-item.yml", "id: duplicate\n")
aItem := writeImportFile(t, directory, "a-item.yml", "id: duplicate\n")
request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
{source: File(aItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
}
_, err := testGroupedLoad(request)
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) != 1 {
t.Fatalf("expected item conflict: %v", err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items" {
t.Fatalf("group item conflict = %s %s, want compose.conflict /items", diagnostic.Code, diagnostic.Path.String())
}
files := make(map[string]string)
for _, source := range loadError.Sources() {
files[source.ID] = source.Path
}
locations := append([]Location(nil), diagnostic.Related...)
if diagnostic.Primary != nil {
locations = append(locations, *diagnostic.Primary)
}
wantLocations := map[string]Location{
canonicalTestPath(t, zItem): {Line: 1, Column: 5},
canonicalTestPath(t, aItem): {Line: 1, Column: 5},
}
gotPaths := make(map[string]bool, len(locations))
for _, location := range locations {
want := wantLocations[files[location.SourceID]]
gotPaths[files[location.SourceID]] = location.Line == want.Line && location.Column == want.Column
}
if len(locations) != 2 || !gotPaths[canonicalTestPath(t, zItem)] || !gotPaths[canonicalTestPath(t, aItem)] {
t.Fatalf("group item conflict sites = %#v; source paths = %#v", locations, files)
}
}
func TestUnderlayItemHelperConflictUsesScheduledGroupIndex(t *testing.T) {
directory := t.TempDir()
zBad := writeImportFile(t, directory, "z-bad.yml", "configkit: {imports: [{path: helper.yml}]}\nid: duplicate\n")
helper := writeImportFile(t, directory, "helper.yml", "id: duplicate\n")
aGood := writeImportFile(t, directory, "a-good.yml", "id: good\n")
request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{
{source: File(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem},
{source: File(aGood), destination: Keys("items"), placementMode: placementModeSequenceItem},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
}
_, err := testGroupedLoad(request)
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) != 1 {
t.Fatalf("expected item helper conflict: %v", err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items/1/id" {
t.Fatalf("group item helper conflict = %s %s, want compose.conflict /items/1/id", diagnostic.Code, diagnostic.Path.String())
}
files := make(map[string]string)
for _, source := range loadError.Sources() {
files[source.ID] = source.Path
}
if len(files) != 3 {
t.Fatalf("group acquisition stopped before all sources were read: %#v", files)
}
locations := append([]Location(nil), diagnostic.Related...)
if diagnostic.Primary != nil {
locations = append(locations, *diagnostic.Primary)
}
wantLocations := map[string]Location{
canonicalTestPath(t, zBad): {Line: 2, Column: 5},
canonicalTestPath(t, helper): {Line: 1, Column: 5},
}
gotPaths := make(map[string]bool, len(locations))
for _, location := range locations {
want := wantLocations[files[location.SourceID]]
gotPaths[files[location.SourceID]] = location.Line == want.Line && location.Column == want.Column
}
if len(locations) != 2 || !gotPaths[canonicalTestPath(t, zBad)] || !gotPaths[canonicalTestPath(t, helper)] {
t.Fatalf("group item helper conflict sites = %#v; source paths = %#v", locations, files)
}
}
func TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle(t *testing.T) {
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: imported.yml}]}\nversion: v1\nport: 5\nsettings: {shared: main}\n")},