From 08e03f6a38e7dbe314c787ad6e37dbe1533dfa3d Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 14 Oct 2026 17:41:35 +0000 Subject: [PATCH] Demonstrate entry-declared import destinations --- docs/consumers/README.md | 32 ++++++++++++------- docs/roadmap/implementation.md | 13 +++++++- examples/consumers/destinations.go | 17 ++++------ examples/consumers/destinations_test.go | 27 ++++++++++++++-- examples/consumers/layered.go | 26 ++++++++++++++- examples/consumers/layered_test.go | 28 ++++++++++++++++ .../collections/weather/collection.yml | 5 +++ .../collections/weather/metadata.yml | 4 ++- .../collections/weather/profiles/index.yml | 4 +++ .../collections/weather/prompts/index.yml | 4 +++ .../collections/weather/version.yml | 1 + .../testdata/ordinary-imports/config.yml | 6 ++++ .../testdata/ordinary-imports/metadata.yml | 2 ++ .../ordinary-imports/prompts/brief.yml | 2 ++ 14 files changed, 143 insertions(+), 28 deletions(-) create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/version.yml create mode 100644 examples/consumers/testdata/ordinary-imports/config.yml create mode 100644 examples/consumers/testdata/ordinary-imports/metadata.yml create mode 100644 examples/consumers/testdata/ordinary-imports/prompts/brief.yml diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 50dd972..c43e4ed 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -17,7 +17,7 @@ sanitized shapes using only ConfigKit and the Go standard library: | Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file | | Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem | | Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource | -| Mounted collections | Literal input destinations, peer prompt/profile fragments, local inheritance, imported collection metadata, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources | +| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources | | Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset | | Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation | @@ -109,12 +109,21 @@ and may fail independently of the completed configuration load. The [configuration contract](../config.md#source-resource-contexts) owns exact lifetime and precedence rules. +[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root +counterpart: its entry opts into imports through `Load`, imports metadata at the +root, and places a prompt beneath `prompts` using YAML. The executable example +and behavior test are in +[`layered_test.go`](../../examples/consumers/layered_test.go). + ## Input destinations and mounted collections -Main and underlay inputs use the same destination field; imported application -contributions inherit the declaring input's destination. A destination is an -application mapping path, independent of filesystem context. The configuration -contract owns the complete behavior and validation rules. +Main and underlay inputs use the same destination field. An import without a +`destination` inherits its declaring document's effective destination; an +explicit sequence of key strings appends a relative suffix. Nested imports +append to their immediate parent's effective destination, while filesystem +paths remain relative to the declaring file. Destinations are application +mapping paths, independent of filesystem context. The configuration contract +owns the complete behavior and validation rules. The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go) mounts one mapping source beneath literal collection/prompt keys and loads a @@ -123,12 +132,13 @@ default. Consumers pass paths, not filenames or namespace strings for ConfigKit to interpret. [`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an -embedded collection manifest with a source-root import, separate prompt and -profile peers, and a higher-priority input that changes only one prompt's -ordinary `version` field. The main document alone supplies the application -schema version. A prompt's local `extends` relationship resolves after all -tiers, so `custom` inherits the final `brief` content and fields while retaining -its own profile selection. +embedded collection manifest mounted beneath a literal collection path. The +entry's YAML places nested metadata imports at the collection root and prompt +and profile peers beneath their own keys. A higher-priority input changes only +one prompt's ordinary `version` field. The main document alone supplies the +application schema version. A prompt's local `extends` relationship resolves +after all tiers, so `custom` inherits the final `brief` content and fields while +retaining its own profile selection. The resolver follows each effective `content_file` origin to its declaring source context. This also works for `custom`'s inherited resource. The diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 17146e8..4691ec7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -380,7 +380,7 @@ removed descendants. No limitations. ## Stage 8 — Demonstrate ordinary and mounted collection entries -Status: planned. Scope: maintained public examples and their behavior tests. +Status: complete. Scope: maintained public examples and their behavior tests. 1. Add a compact ordinary-root example: its entry imports metadata and a fragment with `destination: [prompts]`, using existing `Load` import opt-in and no @@ -402,6 +402,17 @@ Validate: `go test ./examples/consumers`, applicable executable `Example` tests, local links, and everyday check. Done: ordinary and layered workflows operate without the deferred source-header proposal. +Evidence (2026-10-14): based on `a20ba0a`, using `go1.26.8 linux/amd64`. +`go test ./examples/consumers`, root executable examples, local documentation +links, and `./scripts/check.sh` passed. The ordinary-root sample uses `Load` +with import opt-in and entry-authored root/prompt placement. The mounted +collection example now declares metadata, prompt, and profile destinations in +its entry YAML and exercises nested imports, while Go selects only the mount +and main-owned schema policy. Behavior checks cover strict peer composition, +post-tier prompt/profile inheritance, the version-only operator tier, effective +source placement, and reading inherited prompt content from its source context. +No limitations. + ## Stage 9 — Reconcile documentation and audit the complete feature Status: planned. Scope: final consistency and evidence; no publication work. diff --git a/examples/consumers/destinations.go b/examples/consumers/destinations.go index 4656af2..6f3c184 100644 --- a/examples/consumers/destinations.go +++ b/examples/consumers/destinations.go @@ -43,9 +43,9 @@ type CollectionProfile struct { } // LoadCollectionBundle demonstrates one layered collection load. Its main -// document owns the application schema version; imported collection metadata, -// prompt/profile peers, and an operator prompt-version override use ordinary -// destinations and domain fields. +// document owns the application schema version; the mounted entry declares +// relative destinations for collection metadata, prompt/profile peers, and +// their nested imports. The operator version override remains a separate tier. func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) { files, err := fs.Sub(bundledFiles, "testdata/collection-bundle") if err != nil { @@ -54,7 +54,6 @@ func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) { bundle := configkit.NewFilesystem("collection bundle", files) collectionDestination := configkit.Keys("collections", "weatherreporter") promptDestination := collectionDestination.Key("prompts") - profileDestination := collectionDestination.Key("profiles") return configkit.LoadLayered( configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))}, configkit.LayeredOptions[CollectionBundleConfig]{ @@ -69,13 +68,9 @@ func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) { Validate: validateCollectionBundle, }, Underlays: []configkit.Underlay{ - {Inputs: []configkit.Input{ - {Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination}, - {Source: bundle.File("collections/weather/prompts/brief.yml"), Destination: promptDestination}, - {Source: bundle.File("collections/weather/prompts/custom.yml"), Destination: promptDestination}, - {Source: bundle.File("collections/weather/profiles/compact.yml"), Destination: profileDestination}, - {Source: bundle.File("collections/weather/profiles/weather.yml"), Destination: profileDestination}, - }}, + {Inputs: []configkit.Input{{ + Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination, + }}}, {Inputs: []configkit.Input{{ Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination, }}}, diff --git a/examples/consumers/destinations_test.go b/examples/consumers/destinations_test.go index 492127c..ff76eec 100644 --- a/examples/consumers/destinations_test.go +++ b/examples/consumers/destinations_test.go @@ -69,22 +69,43 @@ func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) { t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok) } var metadataSource configkit.SourceInfo + var collectionEntrySource configkit.SourceInfo + var versionSource configkit.SourceInfo var parentPromptSource configkit.SourceInfo + var promptIndexSource configkit.SourceInfo + var profileIndexSource configkit.SourceInfo var promptVersionSource configkit.SourceInfo for _, source := range result.Sources() { switch source.Path { + case "collections/weather/collection.yml": + collectionEntrySource = source case "collections/weather/metadata.yml": metadataSource = source + case "collections/weather/version.yml": + versionSource = source + case "collections/weather/prompts/index.yml": + promptIndexSource = source case "collections/weather/prompts/brief.yml": parentPromptSource = source + case "collections/weather/profiles/index.yml": + profileIndexSource = source case "operators/weather-prompt-version.yml": promptVersionSource = source } } collectionVersionLocation := collectionVersion.Origin.Locations[0] - if metadataSource.ID == "" || collectionVersionLocation.SourceID != metadataSource.ID || - metadataSource.Destination.String() != "/collections/weatherreporter" || collectionVersionLocation.Line != 1 { - t.Fatalf("imported collection version source = %#v, %#v", metadataSource, collectionVersionLocation) + if collectionEntrySource.ID != "source-2" || collectionEntrySource.Destination.String() != "/collections/weatherreporter" || + metadataSource.ID == "" || metadataSource.Destination.String() != "/collections/weatherreporter" || + metadataSource.ImportedFrom == nil || metadataSource.ImportedFrom.SourceID != collectionEntrySource.ID || + versionSource.ID == "" || collectionVersionLocation.SourceID != versionSource.ID || versionSource.Destination.String() != "/collections/weatherreporter" || + versionSource.ImportedFrom == nil || versionSource.ImportedFrom.SourceID != metadataSource.ID || collectionVersionLocation.Line != 1 { + t.Fatalf("nested collection version provenance = entry %#v, metadata %#v, version %#v, location %#v", collectionEntrySource, metadataSource, versionSource, collectionVersionLocation) + } + if promptIndexSource.ID == "" || promptIndexSource.Destination.String() != "/collections/weatherreporter/prompts" || + promptIndexSource.ImportedFrom == nil || promptIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID || + profileIndexSource.ID == "" || profileIndexSource.Destination.String() != "/collections/weatherreporter/profiles" || + profileIndexSource.ImportedFrom == nil || profileIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID { + t.Fatalf("nested prompt/profile placement = prompt %#v profile %#v", promptIndexSource, profileIndexSource) } if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 { t.Fatalf("prompt version source = %#v", promptVersionSource) diff --git a/examples/consumers/layered.go b/examples/consumers/layered.go index 8454496..28e35dd 100644 --- a/examples/consumers/layered.go +++ b/examples/consumers/layered.go @@ -9,7 +9,7 @@ import ( configkit "gitea.maximumdirect.net/eric/ConfigKit" ) -//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle +//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports var bundledFiles embed.FS // BundledConfig is a small configuration loaded directly from an embedded file. @@ -18,6 +18,30 @@ type BundledConfig struct { Enabled bool `yaml:"enabled"` } +type PromptBundleConfig struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + Prompts map[string]BundlePrompt `yaml:"prompts"` +} + +type BundlePrompt struct { + Summary string `yaml:"summary"` +} + +// LoadPromptBundle demonstrates ordinary Load with opt-in imports whose +// destinations are declared relative to the entry document. +func LoadPromptBundle() (*configkit.Result[PromptBundleConfig], error) { + files, err := fs.Sub(bundledFiles, "testdata/ordinary-imports") + if err != nil { + return nil, err + } + return configkit.Load[PromptBundleConfig]( + configkit.NewFilesystem("prompt bundle", files).File("config.yml"), + configkit.Options[PromptBundleConfig]{Imports: true}, + ) +} + // LoadBundled demonstrates a native filesystem source without layered options. func LoadBundled() (*configkit.Result[BundledConfig], error) { files, err := fs.Sub(bundledFiles, "testdata/native-source") diff --git a/examples/consumers/layered_test.go b/examples/consumers/layered_test.go index f72cd61..de6deba 100644 --- a/examples/consumers/layered_test.go +++ b/examples/consumers/layered_test.go @@ -20,6 +20,34 @@ func Example_nativeFilesystem() { // Output: embedded false } +func Example_rootRelativeImports() { + result, err := consumers.LoadPromptBundle() + if err != nil { + fmt.Println(err) + return + } + source := result.Sources()[2] + fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err) + // Output: Root import example 1.0 A root-loaded prompt. /prompts +} + +func TestPromptBundleUsesRelativeImportDestinations(t *testing.T) { + result, err := consumers.LoadPromptBundle() + if err != nil { + t.Fatal(err) + } + if result.Config.Name != "Root import example" || result.Config.Version != "1.0" || result.Config.Prompts["brief"].Summary != "A root-loaded prompt." { + t.Fatalf("root import result = %#v", result.Config) + } + sources := result.Sources() + if len(sources) != 3 || sources[0].Destination.String() != configkit.Keys().String() || + sources[1].Path != "metadata.yml" || sources[1].Destination.String() != configkit.Keys().String() || + sources[2].Path != "prompts/brief.yml" || sources[2].Destination.String() != "/prompts" || + sources[2].ImportedFrom == nil || sources[2].ImportedFrom.SourceID != sources[0].ID { + t.Fatalf("root import sources = %#v", sources) + } +} + func Example_layeredCatalog() { result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml")) if err != nil { diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/collection.yml b/examples/consumers/testdata/collection-bundle/collections/weather/collection.yml index 5c2a448..3065c9d 100644 --- a/examples/consumers/testdata/collection-bundle/collections/weather/collection.yml +++ b/examples/consumers/testdata/collection-bundle/collections/weather/collection.yml @@ -1,4 +1,9 @@ configkit: imports: - path: metadata.yml + destination: [] + - path: prompts/index.yml + destination: [prompts] + - path: profiles/index.yml + destination: [profiles] name: Weather Reporter diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml b/examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml index 40811a7..bd19389 100644 --- a/examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml +++ b/examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml @@ -1,2 +1,4 @@ -version: "2.4.0" +configkit: + imports: + - path: version.yml description: Imported collection metadata. diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml b/examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml new file mode 100644 index 0000000..199cd4e --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml @@ -0,0 +1,4 @@ +configkit: + imports: + - path: compact.yml + - path: weather.yml diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml b/examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml new file mode 100644 index 0000000..086113b --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml @@ -0,0 +1,4 @@ +configkit: + imports: + - path: brief.yml + - path: custom.yml diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/version.yml b/examples/consumers/testdata/collection-bundle/collections/weather/version.yml new file mode 100644 index 0000000..04848d3 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/version.yml @@ -0,0 +1 @@ +version: "2.4.0" diff --git a/examples/consumers/testdata/ordinary-imports/config.yml b/examples/consumers/testdata/ordinary-imports/config.yml new file mode 100644 index 0000000..c6b31cb --- /dev/null +++ b/examples/consumers/testdata/ordinary-imports/config.yml @@ -0,0 +1,6 @@ +configkit: + imports: + - path: metadata.yml + - path: prompts/brief.yml + destination: [prompts] +name: Root import example diff --git a/examples/consumers/testdata/ordinary-imports/metadata.yml b/examples/consumers/testdata/ordinary-imports/metadata.yml new file mode 100644 index 0000000..56fc70a --- /dev/null +++ b/examples/consumers/testdata/ordinary-imports/metadata.yml @@ -0,0 +1,2 @@ +version: "1.0" +description: Metadata imported into the root document. diff --git a/examples/consumers/testdata/ordinary-imports/prompts/brief.yml b/examples/consumers/testdata/ordinary-imports/prompts/brief.yml new file mode 100644 index 0000000..5eb6a37 --- /dev/null +++ b/examples/consumers/testdata/ordinary-imports/prompts/brief.yml @@ -0,0 +1,2 @@ +brief: + summary: A root-loaded prompt.