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

@@ -148,12 +148,12 @@ func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Locati
}, true
}
func scheduleOrdinaryContributions(scope acquisitionScope, graph importInclusionGraph) acquisitionScope {
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 || (contribution.role != importRoleMain && contribution.role != importRoleMapping) {
if contribution.scopeOwner != scope.owner {
continue
}
positions[contribution.identity] = len(keys)

View File

@@ -220,9 +220,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
}
}
if len(diagnostics) == 0 {
if !mainIsItem {
state.scope = scheduleOrdinaryContributions(state.scope, state.importGraph)
}
state.scheduleAcquisitionScopes(&state.scope)
diagnostics = state.composePendingItems(&state.scope)
}
@@ -572,7 +570,7 @@ func (s *importTraversal) visitItemSource(canonical string, identity importNodeK
itemScope := s.scope
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
if len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
return diagnostics
}
itemScope.destination = Path{}
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
@@ -584,6 +582,15 @@ func (s *importTraversal) visitItemSource(canonical string, identity importNodeK
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 {
for index := range scope.contributions {
contribution := &scope.contributions[index]
@@ -592,7 +599,7 @@ func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnos
}
itemScope := contribution.pendingItem
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))
for _, nested := range itemScope.contributions {
@@ -600,7 +607,7 @@ func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnos
}
completed, diagnostics := composeDocuments(documents...)
if len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination)
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
}
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*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
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
// 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 {
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))
}
}

View File

@@ -812,8 +812,14 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
if rootScope.owner != graph.nodes[0].key || len(rootScope.contributions) != 2 {
t.Fatalf("root acquisition scope = %#v", rootScope)
}
item := rootScope.contributions[1]
if item.identity != graph.nodes[1].key || item.role != importRoleItem || item.scopeOwner != rootScope.owner || item.nested == nil {
var item *acquiredContribution
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)
}
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) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("initial filesystem contract covers Linux and macOS")