Schedule item-local import scopes

This commit is contained in:
2026-10-19 19:39:19 +00:00
parent a4cbd6390e
commit af43d6f438
5 changed files with 181 additions and 46 deletions

View File

@@ -936,11 +936,14 @@ version-callback order. Ordinary mapping contributions in a main tier are assemb
dependency-first: ConfigKit selects the lexically smallest stable source key among dependency-first: ConfigKit selects the lexically smallest stable source key among
the contributions whose imports are complete. Independent branches may interleave, the contributions whose imports are complete. Independent branches may interleave,
and the main body may be assembled after its imports. Sequence-item contributions and the main body may be assembled after its imports. Sequence-item contributions
retain their declaration order and local helper scope; they are not flattened into in that tier are nodes in the same schedule; each selected item first assembles its
this ordinary mapping schedule. Assembly order never permits replacement. Required own strict mapping scope, with helpers before the bodies that import them. Those
missing paths fail; `optional: true` suppresses only target nonexistence, not invalid local scopes do not use application collection policies and are not flattened into
paths, containment failures, directories, permissions, parse errors, or composition the outer schedule. Item positions and diagnostic paths follow the resulting
errors. assembly order.
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.
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
classes, and backslash escapes are supported, while recursive `**` syntax is invalid. classes, and backslash escapes are supported, while recursive `**` syntax is invalid.

View File

@@ -51,7 +51,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
} }
} }
func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) { func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
t.Parallel() t.Parallel()
filesystem := configkit.NewFilesystem("history", fstest.MapFS{ filesystem := configkit.NewFilesystem("history", fstest.MapFS{
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")}, "main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
@@ -68,19 +68,17 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
sourceOrder := make(map[string]int)
sourceFiles := make(map[string]string) sourceFiles := make(map[string]string)
for index, source := range result.Sources() { for _, source := range result.Sources() {
sourceOrder[source.ID] = index
sourceFiles[source.ID] = source.Path sourceFiles[source.ID] = source.Path
} }
for _, test := range []struct { for _, test := range []struct {
path configkit.Path path configkit.Path
file, text string file, text string
}{ }{
{configkit.Keys("records").Index(1).Key("text"), "helper-one.yml", "first helper\n"}, {configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
{configkit.Keys("records").Index(2).Key("text"), "helper-two.yml", "second helper\n"}, {configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
{configkit.Keys("records").Index(1).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"}, {configkit.Keys("records").Index(0).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
} { } {
explanation, ok := result.Explain(test.path) explanation, ok := result.Explain(test.path)
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 { if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
@@ -95,20 +93,6 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
t.Fatal("metadata and explanation disagree") t.Fatal("metadata and explanation disagree")
} }
} }
explanation, ok := result.Explain(configkit.Keys())
if !ok {
t.Fatal("root explanation missing")
}
previous := -1
for _, change := range explanation.History {
for _, location := range change.Origin.Locations {
current := sourceOrder[location.SourceID]
if current < previous {
t.Fatalf("source %s replayed out of traversal order", location.SourceID)
}
previous = current
}
}
} }
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) { func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
@@ -158,9 +142,9 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
name, file, path string name, file, path string
grouped, itemMain bool grouped, itemMain bool
}{ }{
{"preceding authored and imported items", "main.yml", "/records/2/id", false, false}, {"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
{"nested item", "nested.yml", "/records/1/children/1/id", false, false}, {"nested item", "nested.yml", "/records/0/children/0/id", false, false},
{"preceding grouped input", "group.yml", "/records/2/id", true, false}, {"preceding grouped input", "group.yml", "/records/1/id", true, false},
{"item main source", "bad.yml", "/records/0/id", false, true}, {"item main source", "bad.yml", "/records/0/id", false, true},
{"grouped item source", "bad.yml", "/records/1/id", true, true}, {"grouped item source", "bad.yml", "/records/1/id", true, true},
} { } {
@@ -193,8 +177,16 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
for _, source := range loadError.Sources() { for _, source := range loadError.Sources() {
files[source.ID] = source.Path files[source.ID] = source.Path
} }
if diagnostic.Primary == nil || files[diagnostic.Primary.SourceID] != "bad.yml" || len(diagnostic.Related) != 1 || files[diagnostic.Related[0].SourceID] != "helper.yml" { locations := append([]configkit.Location(nil), diagnostic.Related...)
t.Fatal("conflict lost physical source locations") if diagnostic.Primary != nil {
locations = append(locations, *diagnostic.Primary)
}
gotFiles := make(map[string]bool, len(locations))
for _, location := range locations {
gotFiles[files[location.SourceID]] = location.Line == 1 && location.Column == 5
}
if len(locations) != 2 || !gotFiles["bad.yml"] || !gotFiles["helper.yml"] {
t.Fatalf("conflict lost physical source locations: diagnostic=%#v sources=%#v", diagnostic, files)
} }
}) })
} }
@@ -326,7 +318,7 @@ items:
t.Fatalf("load item glob: %v", err) t.Fatalf("load item glob: %v", err)
} }
items := result.Config["items"].([]any) items := result.Config["items"].([]any)
wantIDs := []string{"authored", "imported-a", "imported-b"} wantIDs := []string{"imported-a", "imported-b", "authored"}
for index, item := range items { for index, item := range items {
if got := item.(map[string]any)["id"]; got != wantIDs[index] { if got := item.(map[string]any)["id"]; got != wantIDs[index] {
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index]) t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
@@ -344,10 +336,10 @@ items:
} }
} }
metadata := result.Metadata() metadata := result.Metadata()
for index := 1; index < len(wantIDs); index++ { for index := 0; index < 2; index++ {
path := configkit.Keys("items").Index(index).Key("id") path := configkit.Keys("items").Index(index).Key("id")
info, ok := metadata.Lookup(path) info, ok := metadata.Lookup(path)
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index-1] { if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index] {
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok) t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
} }
} }

View File

@@ -148,12 +148,12 @@ func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Locati
}, true }, true
} }
func scheduleOrdinaryContributions(scope acquisitionScope, graph importInclusionGraph) acquisitionScope { func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph) acquisitionScope {
positions := make(map[importNodeKey]int, len(scope.contributions)) positions := make(map[importNodeKey]int, len(scope.contributions))
slots := make([]int, 0, len(scope.contributions)) slots := make([]int, 0, len(scope.contributions))
keys := make([]dependencyKey, 0, len(scope.contributions)) keys := make([]dependencyKey, 0, len(scope.contributions))
for index, contribution := range scope.contributions { for index, contribution := range scope.contributions {
if contribution.scopeOwner != scope.owner || (contribution.role != importRoleMain && contribution.role != importRoleMapping) { if contribution.scopeOwner != scope.owner {
continue continue
} }
positions[contribution.identity] = len(keys) positions[contribution.identity] = len(keys)

View File

@@ -220,9 +220,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
} }
} }
if len(diagnostics) == 0 { if len(diagnostics) == 0 {
if !mainIsItem { state.scheduleAcquisitionScopes(&state.scope)
state.scope = scheduleOrdinaryContributions(state.scope, state.importGraph)
}
diagnostics = state.composePendingItems(&state.scope) diagnostics = state.composePendingItems(&state.scope)
} }
@@ -572,7 +570,7 @@ func (s *importTraversal) visitItemSource(canonical string, identity importNodeK
itemScope := s.scope itemScope := s.scope
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, parentScope, destination) return diagnostics
} }
itemScope.destination = Path{} itemScope.destination = Path{}
s.scope.contributions = append(s.scope.contributions, acquiredContribution{ s.scope.contributions = append(s.scope.contributions, acquiredContribution{
@@ -584,6 +582,15 @@ func (s *importTraversal) visitItemSource(canonical string, identity importNodeK
return nil return nil
} }
func (s *importTraversal) scheduleAcquisitionScopes(scope *acquisitionScope) {
*scope = scheduleAcquisitionScope(*scope, s.importGraph)
for index := range scope.contributions {
if pending := scope.contributions[index].pendingItem; pending != nil {
s.scheduleAcquisitionScopes(pending)
}
}
}
func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnostic { func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnostic {
for index := range scope.contributions { for index := range scope.contributions {
contribution := &scope.contributions[index] contribution := &scope.contributions[index]
@@ -592,7 +599,7 @@ func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnos
} }
itemScope := contribution.pendingItem itemScope := contribution.pendingItem
if diagnostics := s.composePendingItems(itemScope); len(diagnostics) != 0 { if diagnostics := s.composePendingItems(itemScope); len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination) return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
} }
documents := make([]*document.Node, 0, len(itemScope.contributions)) documents := make([]*document.Node, 0, len(itemScope.contributions))
for _, nested := range itemScope.contributions { for _, nested := range itemScope.contributions {
@@ -600,7 +607,7 @@ func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnos
} }
completed, diagnostics := composeDocuments(documents...) completed, diagnostics := composeDocuments(documents...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination) return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
} }
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)} completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)}
projectNestedEvidence(completed, *itemScope) projectNestedEvidence(completed, *itemScope)
@@ -668,10 +675,12 @@ func isCompositionDiagnostic(diagnostic Diagnostic) bool {
// On a composition failure only the earlier contributions are needed to locate // On a composition failure only the earlier contributions are needed to locate
// this item. Project one enclosing scope at a time as traversal unwinds; syntax // this item. Project one enclosing scope at a time as traversal unwinds; syntax
// and access diagnostics keep their original source-local coordinates. // and access diagnostics keep their original source-local coordinates.
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path) []Diagnostic { func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path, beforeContribution int) []Diagnostic {
for _, diagnostic := range diagnostics { for _, diagnostic := range diagnostics {
if isCompositionDiagnostic(diagnostic) { if isCompositionDiagnostic(diagnostic) {
index := priorSequenceLength([]acquisitionScope{parent}, destination) prefix := parent
prefix.contributions = parent.contributions[:beforeContribution]
index := priorSequenceLength([]acquisitionScope{prefix}, destination)
return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index)) return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index))
} }
} }

View File

@@ -812,8 +812,14 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
if rootScope.owner != graph.nodes[0].key || len(rootScope.contributions) != 2 { if rootScope.owner != graph.nodes[0].key || len(rootScope.contributions) != 2 {
t.Fatalf("root acquisition scope = %#v", rootScope) t.Fatalf("root acquisition scope = %#v", rootScope)
} }
item := rootScope.contributions[1] var item *acquiredContribution
if item.identity != graph.nodes[1].key || item.role != importRoleItem || item.scopeOwner != rootScope.owner || item.nested == nil { for index := range rootScope.contributions {
if rootScope.contributions[index].role == importRoleItem {
item = &rootScope.contributions[index]
break
}
}
if item == nil || item.identity != graph.nodes[1].key || item.scopeOwner != rootScope.owner || item.nested == nil {
t.Fatalf("outer item contribution = %#v", item) t.Fatalf("outer item contribution = %#v", item)
} }
itemScope := item.nested.acquisition[0] itemScope := item.nested.acquisition[0]
@@ -831,6 +837,131 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
} }
} }
func TestItemContributionsUseDependencyReadySchedules(t *testing.T) {
t.Run("outer independent items", func(t *testing.T) {
type item struct {
ID string `yaml:"id"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: z-item.yml
destination: [items]
placement: sequence-item
- path: a-item.yml
destination: [items]
placement: sequence-item
items: []
`)
zPath := writeImportFile(t, directory, "z-item.yml", "id: z\n")
aPath := writeImportFile(t, directory, "a-item.yml", "id: a\n")
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true, Definition: definition})
if err != nil {
t.Fatalf("load item imports: %v", err)
}
if len(result.Config.Items) != 2 || result.Config.Items[0].ID != "a" || result.Config.Items[1].ID != "z" {
t.Fatalf("scheduled items = %#v", result.Config.Items)
}
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, zPath), canonicalTestPath(t, aPath)}
for index, want := range wantSources {
if got := result.Sources()[index].Path; got != want {
t.Errorf("acquired source %d = %q, want %q", index, got, want)
}
}
})
t.Run("unequal helper branches", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: item.yml
destination: [items]
placement: sequence-item
`)
writeImportFile(t, directory, "item.yml", `configkit:
imports:
- path: b-parent.yml
- path: c-independent.yml
id: item
`)
writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nid: b\n")
writeImportFile(t, directory, "a-dependency.yml", "id: a\n")
writeImportFile(t, directory, "c-independent.yml", "id: c\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != "/items/0/id" || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-4" {
t.Fatalf("item helper conflict = %#v", diagnostic)
}
wantRelated := map[string]bool{"source-3": false, "source-5": false, "source-2": false}
if len(diagnostic.Related) != len(wantRelated) {
t.Fatalf("related sites = %#v", diagnostic.Related)
}
for _, site := range diagnostic.Related {
if _, expected := wantRelated[site.SourceID]; !expected {
t.Errorf("unexpected related site source %q", site.SourceID)
}
wantRelated[site.SourceID] = true
}
for sourceID, found := range wantRelated {
if !found {
t.Errorf("related sites do not include %q", sourceID)
}
}
})
t.Run("selected main item dependencies", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n")
writeImportFile(t, directory, "helper.yml", "id: helper\n")
options := Options[importTestConfig]{
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem,
}
loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != "/items/0/id" || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || len(diagnostic.Related) != 1 || diagnostic.Related[0].SourceID != "source-1" {
t.Fatalf("main item helper conflict = %#v", diagnostic)
}
})
}
func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: item.yml
destination: [items]
placement: sequence-item
`)
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: b-parent.yml}, {path: c-independent.yml}]}\nitem: true\n")
writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nb: true\n")
writeImportFile(t, directory, "a-dependency.yml", "a: true\n")
writeImportFile(t, directory, "c-independent.yml", "c: true\n")
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if len(diagnostics) != 0 {
t.Fatalf("assemble item scope: %#v", diagnostics)
}
var nested *composedDocument
for _, contribution := range input.composition.acquisition[0].contributions {
if contribution.role == importRoleItem {
nested = contribution.nested
break
}
}
if nested == nil || len(nested.acquisition) != 1 {
t.Fatalf("item composition = %#v", nested)
}
wantIDs := []string{"source-4", "source-3", "source-5", "source-2"}
for index, want := range wantIDs {
if got := nested.acquisition[0].contributions[index].source.ID; got != want {
t.Errorf("local contribution %d = %q, want %q", index, got, want)
}
}
}
func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) { func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("initial filesystem contract covers Linux and macOS") t.Skip("initial filesystem contract covers Linux and macOS")