diff --git a/docs/config.md b/docs/config.md index f5f17f8..6d981af 100644 --- a/docs/config.md +++ b/docs/config.md @@ -160,14 +160,21 @@ indexes. Explicit index segments and paths that introduce the reserved `configkit` or mapping-valued `extends` structure are invalid input options and produce a structured `LoadError` before source acquisition. -Source-root `configkit.imports` directives are processed before placement, and -each imported application contribution inherits its input's destination, -including transitive imports. A valid destination on `NoSource()` contributes -nothing; an actual empty mapping materializes its destination containers. Once -placed, contributions follow the same strict composition within a tier and -recursive precedence behavior described above. A selected overlay and CLI -overrides remain rooted at the application root and are not moved beneath the -main input's destination. +Source-root `configkit.imports` directives are processed before placement. An +import without an explicit destination inherits the effective destination of +its importing document, including through nested imports. An import may instead +declare a relative destination as a sequence of literal mapping keys; its keys +append to the importing document's effective destination. Omission and `[]` +add no keys. Nested imports continue relative to their immediate importing +document, each declaration is independent of its siblings, and all matches of +one glob receive the same resulting destination. This logical placement does +not change how the physical import path resolves from its declaring file. + +A valid destination on `NoSource()` contributes nothing; an actual empty +mapping materializes its destination containers. Once placed, contributions +follow the same strict composition within a tier and recursive precedence +behavior described above. A selected overlay and CLI overrides remain rooted at +the application root and are not moved beneath the main input's destination. `SourceInfo.Destination` reports the placement for each acquired input source, including imports. Placement does not change source IDs, acquisition identity, @@ -778,9 +785,13 @@ zero. Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root `configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before application decoding. Each sequence item is a mapping with exactly one nonempty -string `path` or `glob` plus an optional boolean `optional`; all other shapes and -fields are `import.invalid`. A `configkit` key below the root is invalid. With -imports disabled, any occurrence remains `import.disabled`. +string `path` or `glob`, an optional boolean `optional`, and an optional +`destination` sequence of strings. Every destination string is one literal +mapping key and follows the input-destination validation rules above; quote +numeric-looking keys so YAML reads them as strings. Null, scalar destinations, +and non-string members are `import.invalid`, as are all other unsupported shapes +and fields. A `configkit` key below the root is invalid. With imports disabled, +any occurrence remains `import.disabled`. The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0. There is no legacy alias: `_configkit` is ordinary application data, subject to diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a8e8142..d543cdb 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -202,7 +202,7 @@ placement survives import-context and imported-parse errors. No limitations. ## Stage 3 — Accept and document the selected YAML syntax -Status: planned. Scope: directive parsing and first complete public behavior. +Status: complete. Scope: directive parsing and first complete public behavior. 1. Parse sequence-of-strings `destination` in `parseImportDeclaration`. Construct literal keys, normalize with existing destination validation, and @@ -227,6 +227,14 @@ tests, local links, and everyday check. Done: syntax works across existing import-capable entry points and acquisition forms, not a silently ignored or root/path-only subset. +Evidence (2026-10-14): based on `49c0c6d908c0746a0ae49a332691c383ac3ff941`, +using `go1.26.8 linux/amd64`. Engine import/glob/destination tests, root public +destination and mounted-load tests, `TestLocalDocumentationLinks`, and +`./scripts/check.sh` all passed. Coverage includes literal keys, malformed +shapes and members with source-local coordinates, validation before optional +missing/unmatched suppression, and ordinary-root plus mounted layered loads. +No limitations. + ## Stage 4 — Verify composition and effective version ownership Status: planned. Scope: assembly interactions; reuse existing algorithms. diff --git a/facade_destination_test.go b/facade_destination_test.go index d9614af..010314a 100644 --- a/facade_destination_test.go +++ b/facade_destination_test.go @@ -155,6 +155,122 @@ func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) { } } +func TestFacadeRelativeImportDestinationsForRootAndMountedLoads(t *testing.T) { + directory := t.TempDir() + write := func(relative, contents string) string { + t.Helper() + filename := filepath.Join(directory, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return filename + } + + mainPath := write("main.yml", `configkit: + imports: + - path: metadata.yml + destination: [metadata] + - glob: prompts/*.yml + destination: [prompts] + - path: entries/main.yml + destination: [profiles] + - path: sibling.yml + destination: [sibling] +root: main +`) + write("metadata.yml", "name: imported\n") + write("prompts/a.yml", "a: {name: alpha}\n") + write("prompts/b.yml", "b: {name: beta}\n") + write("entries/main.yml", `configkit: + imports: + - path: nested/details.yml + destination: [details] +name: profile +`) + write("entries/nested/details.yml", "summary: nested\n") + write("sibling.yml", "name: separate\n") + + root, err := configkit.Load( + configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true}, + ) + if err != nil { + if loadError, ok := err.(*configkit.LoadError); ok { + t.Fatalf("ordinary root Load: %v, diagnostics %#v", err, loadError.Diagnostics()) + } + t.Fatalf("ordinary root Load: %v", err) + } + mountedDestination := configkit.Keys("collections", "weatherreporter") + mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.File(mainPath), Imports: true, Destination: mountedDestination, + }) + if err != nil { + t.Fatalf("mounted layered Load: %v", err) + } + + for _, test := range []struct { + name string + result *configkit.Result[map[string]any] + prefix []string + }{ + {name: "ordinary root", result: root}, + {name: "mounted entry", result: mounted, prefix: []string{"collections", "weatherreporter"}}, + } { + t.Run(test.name, func(t *testing.T) { + at := func(keys ...string) []string { + return append(append([]string(nil), test.prefix...), keys...) + } + if got := facadeDestinationValue(t, test.result.Config, at("root")...); got != "main" { + t.Fatalf("root value = %#v", got) + } + if got := facadeDestinationValue(t, test.result.Config, at("metadata", "name")...); got != "imported" { + t.Fatalf("metadata value = %#v", got) + } + if got := facadeDestinationValue(t, test.result.Config, at("prompts", "a", "name")...); got != "alpha" { + t.Fatalf("first glob value = %#v", got) + } + if got := facadeDestinationValue(t, test.result.Config, at("prompts", "b", "name")...); got != "beta" { + t.Fatalf("second glob value = %#v", got) + } + if got := facadeDestinationValue(t, test.result.Config, at("profiles", "name")...); got != "profile" { + t.Fatalf("entry value = %#v", got) + } + if got := facadeDestinationValue(t, test.result.Config, at("profiles", "details", "summary")...); got != "nested" { + t.Fatalf("nested import value = %#v", got) + } + if got := facadeDestinationValue(t, test.result.Config, at("sibling", "name")...); got != "separate" { + t.Fatalf("subsequent sibling value = %#v", got) + } + + wantPaths := []string{ + "main.yml", "metadata.yml", "a.yml", "b.yml", "main.yml", "details.yml", "sibling.yml", + } + wantDestinations := []configkit.Path{configkit.Keys(test.prefix...)} + for _, suffix := range [][]string{{"metadata"}, {"prompts"}, {"prompts"}, {"profiles"}, {"profiles", "details"}, {"sibling"}} { + destination := append([]string(nil), test.prefix...) + for _, segment := range suffix { + destination = append(destination, segment) + } + wantDestinations = append(wantDestinations, configkit.Keys(destination...)) + } + sources := test.result.Sources() + if len(sources) != len(wantPaths) { + t.Fatalf("sources = %#v, want %d", sources, len(wantPaths)) + } + for index, source := range sources { + if filepath.Base(source.Path) != wantPaths[index] || source.Destination.String() != wantDestinations[index].String() { + t.Errorf("source %d path/destination = (%q, %q), want base path %q and destination %q", index, source.Path, source.Destination.String(), wantPaths[index], wantDestinations[index].String()) + } + if index > 0 && source.ImportedFrom == nil { + t.Errorf("imported source %d has no physical declaration location", index) + } + } + }) + } +} + func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) { weatherDestination := configkit.Keys("collections", "weather", "prompts") homeDestination := configkit.Keys("collections", "homewatch", "prompts") diff --git a/internal/engine/directives.go b/internal/engine/directives.go index 86e8f0e..61f6079 100644 --- a/internal/engine/directives.go +++ b/internal/engine/directives.go @@ -91,6 +91,27 @@ func parseImportDeclaration(node *document.Node, declarationPath Path) (importDe return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)} } declaration.optional = entry.Value.Text == "true" + case "destination": + if entry.Value.Kind != document.Sequence { + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must be a sequence of strings", entry.Value)} + } + relative := Path{segments: make([]pathSegment, 0, len(entry.Value.Sequence))} + for index, item := range entry.Value.Sequence { + memberPath := fieldPath.Index(index) + if item.Kind != document.String { + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be strings", item)} + } + member := Path{segments: []pathSegment{{kind: pathSegmentKey, key: item.Text}}} + if _, err := normalizeDestination(member); err != nil { + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be non-reserved mapping keys", item)} + } + relative.segments = append(relative.segments, pathSegment{kind: pathSegmentKey, key: item.Text}) + } + normalized, err := normalizeDestination(relative) + if err != nil { + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must contain only non-reserved mapping keys", entry.Value)} + } + declaration.destination = normalized default: return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)} } diff --git a/internal/engine/imports_test.go b/internal/engine/imports_test.go index ef39712..821d37c 100644 --- a/internal/engine/imports_test.go +++ b/internal/engine/imports_test.go @@ -166,6 +166,105 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) { } } +func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + destination string + want Path + }{ + {name: "omitted"}, + {name: "empty sequence", destination: " destination: []\n"}, + { + name: "literal keys", + destination: " destination: ['', '.', '..', 'a/b', '~', '0', configkit2, myextends]\n", + want: Keys("", ".", "..", "a/b", "~", "0", "configkit2", "myextends"), + }, + } { + t.Run(test.name, func(t *testing.T) { + body := "configkit:\n imports:\n - path: child.yml\n" + test.destination + declarations := parseImportDeclarationsYAML(t, body) + if len(declarations) != 1 { + t.Fatalf("declarations = %#v", declarations) + } + got := declarations[0].destination + if got.String() != test.want.String() { + t.Fatalf("destination = %q, want %q", got.String(), test.want.String()) + } + for index, segment := range got.segments { + if segment.kind != pathSegmentKey { + t.Errorf("segment %d kind = %v, want mapping key", index, segment.kind) + } + } + }) + } +} + +func TestParseImportDestinationRejectsInvalidShapesAndMembers(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + value string + wantPath string + badMember string + }{ + {name: "null", value: "null", wantPath: "/configkit/imports/0/destination", badMember: "null"}, + {name: "scalar string", value: "prompts", wantPath: "/configkit/imports/0/destination", badMember: "prompts"}, + {name: "number", value: "3", wantPath: "/configkit/imports/0/destination", badMember: "3"}, + {name: "non-string number", value: "[valid, 7]", wantPath: "/configkit/imports/0/destination/1", badMember: "7"}, + {name: "non-string null", value: "[valid, null]", wantPath: "/configkit/imports/0/destination/1", badMember: "null"}, + {name: "non-string boolean", value: "[valid, true]", wantPath: "/configkit/imports/0/destination/1", badMember: "true"}, + {name: "non-string mapping", value: "[valid, {key: value}]", wantPath: "/configkit/imports/0/destination/1", badMember: "{key"}, + {name: "reserved configkit", value: "[valid, configkit]", wantPath: "/configkit/imports/0/destination/1", badMember: "configkit"}, + {name: "reserved extends", value: "[valid, extends]", wantPath: "/configkit/imports/0/destination/1", badMember: "extends"}, + } { + t.Run(test.name, func(t *testing.T) { + body := "configkit:\n imports:\n - path: child.yml\n destination: " + test.value + "\n" + parsed, diagnostics := parseImportDeclarationYAML(body) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.wantPath { + t.Fatalf("parse = %#v, want import.invalid at %q", diagnostics, test.wantPath) + } + line := strings.Split(body, "\n")[3] + wantColumn := strings.Index(line, test.badMember) + 1 + if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-1" || diagnostics[0].Primary.Line != 4 || diagnostics[0].Primary.Column != wantColumn { + t.Fatalf("invalid destination location = %#v, want line 4 column %d (parsed %v)", diagnostics[0].Primary, wantColumn, parsed) + } + }) + } +} + +func TestImportDestinationValidationPrecedesOptionalSuppression(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + body string + path string + }{ + { + name: "optional missing path", + body: "configkit:\n imports:\n - path: absent.yml\n optional: true\n destination: null\n", + path: "/configkit/imports/0/destination", + }, + { + name: "optional unmatched glob", + body: "configkit:\n imports:\n - glob: absent/*.yml\n optional: true\n destination: [extends]\n", + path: "/configkit/imports/0/destination/0", + }, + } { + t.Run(test.name, func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", test.body) + loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid) + if diagnostics := loadError.Diagnostics(); diagnostics[0].Path.String() != test.path { + t.Fatalf("diagnostic path = %q, want %q", diagnostics[0].Path.String(), test.path) + } + }) + } +} + func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) { t.Run("optional missing", func(t *testing.T) { directory := t.TempDir() @@ -486,3 +585,21 @@ func parseImportMain(t *testing.T, filename string) parsedSource { } return parsed } + +func parseImportDeclarationsYAML(t *testing.T, input string) []importDeclaration { + t.Helper() + declarations, diagnostics := parseImportDeclarationYAML(input) + if len(diagnostics) != 0 { + t.Fatalf("import parse diagnostics = %#v", diagnostics) + } + return declarations +} + +func parseImportDeclarationYAML(input string) ([]importDeclaration, []Diagnostic) { + parsed, diagnostics := parseSource(Bytes("import fixture", []byte(input)), "source-1", defaultParseLimits()) + if len(diagnostics) != 0 { + return nil, diagnostics + } + _, declarations, diagnostics := splitImportDirectives(parsed.document) + return declarations, diagnostics +}