Schedule ordinary import contributions by dependency

This commit is contained in:
2026-10-19 19:29:35 +00:00
parent 91ab37a39d
commit 5ed0f55970
7 changed files with 159 additions and 13 deletions

View File

@@ -930,12 +930,17 @@ directive keys in root files and imported fragments, and rename any application
fields previously named `configkit` to avoid the newly reserved key.
Path declarations use slash-separated relative paths. Absolute paths and network
URLs are invalid. Each path resolves from its declaring file's canonical directory,
and files are traversed depth-first in declaration order: a file contributes its own
body before its imports. Order stabilizes source records and diagnostics but 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.
URLs are invalid. Each path resolves from its declaring file's canonical directory.
Acquisition remains depth-first in declaration order, preserving source-record and
version-callback order. Ordinary mapping contributions in a main tier are assembled
dependency-first: ConfigKit selects the lexically smallest stable source key among
the contributions whose imports are complete. Independent branches may interleave,
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
this ordinary mapping schedule. 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
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.

View File

@@ -13,10 +13,14 @@ import (
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T) {
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
t.Parallel()
for _, grouped := range []bool{false, true} {
for _, sensitivePath := range []string{"", "/records/1/version", "/records/1"} {
index := 0
if grouped {
index = 1
}
for _, sensitivePath := range []string{"", fmt.Sprintf("/records/%d/version", index), fmt.Sprintf("/records/%d", index)} {
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
@@ -46,6 +50,16 @@ func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T)
if err != nil {
t.Fatal(err)
}
if !grouped {
records, ok := result.Config["records"].([]any)
if !ok || len(records) != 2 {
t.Fatalf("scheduled records = %#v", result.Config["records"])
}
firstRecord, ok := records[0].(map[string]any)
if !ok || firstRecord["id"] != "child" {
t.Fatalf("scheduled records = %#v", result.Config["records"])
}
}
sources := result.Sources()
check := sources[len(sources)-1].VersionCheck
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {

View File

@@ -148,6 +148,42 @@ func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Locati
}, true
}
func scheduleOrdinaryContributions(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) {
continue
}
positions[contribution.identity] = len(keys)
slots = append(slots, index)
keys = append(keys, contribution.identity.dependencyKey())
}
if len(keys) < 2 {
return scope
}
edges := make([]dependencyEdge, 0, len(graph.edges))
for _, edge := range graph.edges {
importer, importerOK := positions[edge.importer]
imported, importedOK := positions[edge.imported]
if importerOK && importedOK {
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
}
}
order, _ := scheduleDependencies(keys, edges)
if len(order) != len(keys) {
return scope
}
scheduled := scope
scheduled.contributions = append([]acquiredContribution(nil), scope.contributions...)
for position, scheduledIndex := range order {
scheduled.contributions[slots[position]] = scope.contributions[slots[scheduledIndex]]
}
return scheduled
}
func importKeyFor(access *filesystemAccess, canonical string, synthetic bool) importNodeKey {
kind := "host"
resourceKind := "file"

View File

@@ -220,6 +220,9 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
}
}
if len(diagnostics) == 0 {
if !mainIsItem {
state.scope = scheduleOrdinaryContributions(state.scope, state.importGraph)
}
diagnostics = state.composePendingItems(&state.scope)
}
@@ -256,7 +259,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
protectionRule = &scoped
}
}
if diagnostic := importedVersionClaim(documents, completed, protectionRule, 0); diagnostic != nil {
if diagnostic := importedVersionClaim(documents, localScope, completed, protectionRule); diagnostic != nil {
if rule != nil {
diagnostic.Path = rule.Path.clone()
}
@@ -297,7 +300,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
}
composed, diagnostics := composeDocumentsWithPolicy(policy, documents...)
if composed != nil {
if diagnostic := importedVersionClaim(documents, composed, rule, 0); diagnostic != nil {
if diagnostic := importedVersionClaim(documents, state.scope, composed, rule); diagnostic != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
}
@@ -618,10 +621,17 @@ func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnos
return nil
}
func importedVersionClaim(documents []*document.Node, composed *composedDocument, rule *VersionRule, mainIndex int) *Diagnostic {
func importedVersionClaim(documents []*document.Node, scope acquisitionScope, composed *composedDocument, rule *VersionRule) *Diagnostic {
if composed == nil || rule == nil {
return nil
}
mainIndex := -1
for index, contribution := range scope.contributions {
if contribution.role == importRoleMain {
mainIndex = index
break
}
}
ranges := indexSequenceRanges(composed.sequenceRanges)
for index, contribution := range documents {
if index == mainIndex {

View File

@@ -91,6 +91,80 @@ settings:
}
}
func TestLoadOrdinaryImportsUseDependencyReadyCompositionOrder(t *testing.T) {
type task struct {
ID string `yaml:"id"`
}
type config struct {
Tasks []task `yaml:"tasks"`
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: z-parent.yml
- path: a-independent.yml
tasks: [{id: main}]
`)
zParentPath := writeImportFile(t, directory, "z-parent.yml", `configkit:
imports:
- path: b-dependency.yml
tasks: [{id: z-parent}]
`)
aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n")
bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n")
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{
Imports: true,
sequencePolicy: sequencePolicy{identityFields: map[string]string{"/tasks": "id"}},
})
if len(diagnostics) != 0 {
t.Fatalf("assemble imports: %#v", diagnostics)
}
tasks, ok := documentAtPath(input.document, Keys("tasks"))
if !ok || tasks.Kind != document.Sequence {
t.Fatalf("tasks document = %#v, %v", tasks, ok)
}
wantOrder := []string{"a-independent", "b-dependency", "z-parent", "main"}
if len(tasks.Sequence) != len(wantOrder) {
t.Fatalf("tasks = %#v", tasks.Sequence)
}
for index, want := range wantOrder {
id, found := documentAtPath(tasks, Path{}.Index(index).Key("id"))
if !found || id.Text != want {
t.Errorf("task %d ID = %#v, want %q", index, id, want)
}
}
wantAcquisition := []string{
canonicalTestPath(t, mainPath), canonicalTestPath(t, zParentPath),
canonicalTestPath(t, bDependencyPath), canonicalTestPath(t, aIndependentPath),
}
for index, want := range wantAcquisition {
if got := input.sources[index].Path; got != want {
t.Errorf("acquired source %d = %q, want %q", index, got, want)
}
}
}
func TestAcquiredContextsUseSelectedFileWhenSourceRecordsAreReordered(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "settings: {root: main}\n")
importedPath := writeImportFile(t, directory, "nested/imported.yml", "settings: {nested: imported}\n")
sources := []SourceInfo{
{ID: "source-2", Path: canonicalTestPath(t, importedPath), Kind: SourceKindFile},
{ID: "source-1", Path: canonicalTestPath(t, mainPath), Kind: SourceKindFile},
}
contexts, diagnostic := contextsForAcquiredSources(File(mainPath), nil, nil, sources)
if diagnostic != nil {
t.Fatalf("source contexts: %#v", diagnostic)
}
if got := contexts["source-1"].BaseDir; got != "." {
t.Errorf("main source base = %q, want root-relative current directory", got)
}
if got := contexts["source-2"].BaseDir; got != "nested" {
t.Errorf("imported source base = %q, want nested", got)
}
}
func TestLoadImportsFromProfilesDirectoryAsBaseInput(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/shared.yml}]}\n")

View File

@@ -161,7 +161,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\nitems: [{id: authored}]\n")
writeImportFile(t, directory, "fragment.yml", "items: [{id: imported}]\n")
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true, Definition: definition})
if err != nil || len(result.Config.Items) != 2 || result.Config.Items[1].ID != "imported" {
if err != nil || len(result.Config.Items) != 2 || result.Config.Items[0].ID != "imported" || result.Config.Items[1].ID != "authored" {
t.Fatalf("registered imported list = %#v, %v", result, err)
}
})

View File

@@ -23,9 +23,16 @@ func contextsForAcquiredSources(source Source, context *FilesystemContext, baseS
filesystem = context.FS.namespace.filesystem
rootPath, basePath = context.Root, context.BaseDir
} else if source.kind == sourceFile {
filePath := sources[0].Path
filePath := source.path
if baseSource != nil {
filePath = baseSource.Path
} else {
canonical, err := canonicalExistingPath(filePath)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "selected source could not be canonicalized", nil)
return nil, &value
}
filePath = canonical
}
basePath = filepath.Dir(filePath)
rootPath = basePath