diff --git a/docs/config.md b/docs/config.md index da60fa5..26ab4c2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go index 9546097..ffb0478 100644 --- a/facade_source_version_mode_test.go +++ b/facade_source_version_mode_test.go @@ -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 { diff --git a/internal/engine/import_graph.go b/internal/engine/import_graph.go index 2c71e55..7e3c0f1 100644 --- a/internal/engine/import_graph.go +++ b/internal/engine/import_graph.go @@ -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" diff --git a/internal/engine/imports.go b/internal/engine/imports.go index ff5700e..c2221cc 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -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 { diff --git a/internal/engine/imports_test.go b/internal/engine/imports_test.go index 7f1eaf4..b777e52 100644 --- a/internal/engine/imports_test.go +++ b/internal/engine/imports_test.go @@ -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") diff --git a/internal/engine/sequence_registration_test.go b/internal/engine/sequence_registration_test.go index 6fb2bdb..6fc2aaf 100644 --- a/internal/engine/sequence_registration_test.go +++ b/internal/engine/sequence_registration_test.go @@ -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) } }) diff --git a/internal/engine/source_context.go b/internal/engine/source_context.go index 2da7c24..ba61ffe 100644 --- a/internal/engine/source_context.go +++ b/internal/engine/source_context.go @@ -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