Enable explicit configuration imports and overlays

This commit is contained in:
2026-09-26 18:12:27 +00:00
parent 36e7e857f5
commit 9ac7006a79
15 changed files with 204 additions and 30 deletions

View File

@@ -26,7 +26,11 @@ go run ./cmd/scriptorium render \
- `scriptorium inspect prompt`: inspect one prompt definition without model execution.
- `scriptorium inspect profile`: inspect one effective profile without model execution.
All commands accept `--config <path>` and reject positional arguments. `run`,
All commands accept `--config <path>` and `--config-overlay <name>` and reject
positional arguments. `--config-overlay` selects one application configuration
overlay; it is separate from `--profile`, which selects a Promptkit execution
profile. See [configuration composition](config.md#explicit-imports-and-selected-overlays)
for name, root, and no-base behavior. `run`,
`render`, `serve`, and `inspect prompt` require an effective `prompt_dir`;
`inspect profile` does not. Supply the directory through the configuration
contract or a supported `--prompt-dir` flag. Explicit application flags,
@@ -51,6 +55,7 @@ Optional flags:
| Flag | Meaning |
| --- | --- |
| `--config <path>` | Application configuration file. |
| `--config-overlay <name>` | Selected application configuration overlay. |
| `--prompt-dir <dir>` | Prompt-definition directory override. |
| `--profile-dir <dir>` | Custom profile-directory override. |
| `--schema-dir <dir>` | Schema base-directory override. |
@@ -148,7 +153,7 @@ scriptorium render [flags]
```
`--prompt <id>` is required. The following optional flags are supported:
`--config`, `--prompt-dir`, `--profile-dir`, `--schema-dir`, `--prompt-version`, `--profile`,
`--config`, `--config-overlay`, `--prompt-dir`, `--profile-dir`, `--schema-dir`, `--prompt-version`, `--profile`,
`--input`, `--var`, `--out`, `--llm-base-url`,
`--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`,
`--reasoning-effort`, `--session-id`, `--timeout`, `--transient-retries`, and `--format text|json`. Their meanings match the corresponding
@@ -174,6 +179,7 @@ Optional flags:
| Flag | Meaning |
| --- | --- |
| `--config <path>` | Application configuration file. |
| `--config-overlay <name>` | Selected application configuration overlay. |
| `--addr <listen-address>` | HTTP listen-address override. |
| `--prompt-dir <dir>` | Prompt-definition directory override. |
| `--profile-dir <dir>` | Custom profile-directory override. |
@@ -190,7 +196,7 @@ schemas, and error codes are defined in the [HTTP API reference](api.md).
```text
scriptorium inspect prompt --prompt ID [--prompt-version VERSION]
[--config PATH] [--prompt-dir DIR] [--format text|json] [--out PATH]
[--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out PATH]
```
`--prompt` is required. Inspection uses normal configuration discovery and a
@@ -203,7 +209,7 @@ template, reserve backend capacity, or call a model.
```text
scriptorium inspect profile --profile ID
[--config PATH] [--profile-dir DIR] [--format text|json] [--out PATH]
[--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out PATH]
```
`--profile` is required; built-in profiles need no prompt directory. Inspection

View File

@@ -20,8 +20,9 @@ When `--config` is omitted, Scriptorium searches
If neither exists, it uses built-in defaults. An explicit `--config` path must
exist and decode successfully.
The maintained [minimal configuration](../examples/config.yml) and
[complete configuration](../examples/config.full.yml) are copyable examples.
The maintained [minimal configuration](../examples/config.yml),
[complete configuration](../examples/config.full.yml), and
[composed configuration](../examples/config.composed.yml) are copyable examples.
## Application Configuration File
@@ -31,8 +32,7 @@ An explicit empty string is a value: it clears a file-provided optional path
or, for required `prompt_dir`, fails the command's prerequisite. A scalar
`null` is not an empty string and is rejected for scalar fields; nullable
fields such as `transient_retries` retain their documented meaning. Raw
API-key fields are not accepted. Imports and selected overlays are not yet
enabled for application configuration.
API-key fields are not accepted.
ConfigKit applies defaults, the selected file, then only explicitly supplied
application CLI flags before validating the result. Paths in the application
@@ -40,6 +40,43 @@ document remain relative to the process working directory, not to the file's
directory. Load diagnostics are printed safely on stderr; invalid application
configuration exits with status 1.
### Explicit imports and selected overlays
A file-backed configuration may declare `configkit.imports` with relative
`path` or `glob` entries. Each entry is explicit; Scriptorium does not scan
for fragments on its own. ConfigKit composes the selected file and imports by
conflict, so two fragments cannot define the same field merely to establish
precedence. A required missing import fails; an entry may use
`optional: true` to suppress target nonexistence. Imports are resolved from
the declaring file's canonical directory and remain within the selected
file's canonical directory by default. Scriptorium does not widen that root.
`--config-overlay NAME` selects one file at `overlays/NAME.yml` below that
root. The name must use letters, digits, `_`, or `-`; it is not a path. Empty
and exact lowercase `default` select no overlay. There is no fallback,
case folding, inherited stack, or automatic selection; a missing selected
overlay fails. ConfigKit merges mapping fields and replaces other values,
including sequences and explicit nulls, before applying explicit CLI flags.
An active overlay needs a selected base file. No-source defaults-only loads
cannot use imports or an active overlay.
The [composed example](../examples/config.composed.yml) imports two fragments
and has a selectable [local overlay](../examples/overlays/local.yml). From the
repository root:
```bash
go run ./cmd/scriptorium render \
--config ./examples/config.composed.yml --config-overlay local \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml
```
Import and overlay file locations are constrained by ConfigKit;
*application path values* such as `prompt_dir` remain relative
to the process working directory even when authored in a fragment or overlay.
These source constraints are separate from HTTP artifact containment.
| Field | Default | Meaning |
| --- | --- | --- |
| `prompt_dir` | unset | Promptkit prompt-definition source directory. `run`, `render`, `serve`, and `inspect prompt` require an effective value. |

View File

@@ -40,8 +40,10 @@ implicit discovery. One ConfigKit load applies explicit application overrides
before validation and returns a result containing the resolved document and
its frozen inspection records. `Settings` converts that document into the
existing runtime settings with backend IDs sorted; it does not read files or
apply another merge. Imports and selected overlays remain disabled until their
own user-facing integration is ready.
apply another merge. File-backed loads enable explicit ConfigKit imports and
select one named overlay when requested. ConfigKit uses the selected file's
canonical directory as the source root; the application does not widen it.
Application path values retain process-working-directory-relative meaning.
Every configuration-loading command uses this boundary. The CLI maps only
explicitly visited application flags to ConfigKit overrides and prints

View File

@@ -44,6 +44,14 @@ schema sources that follow the tagged framework formats. For an HTTP deployment
that accepts file artifacts, use a dedicated, narrow artifact directory rather
than a general-purpose or sensitive filesystem tree.
If application configuration uses explicit imports or a selected overlay,
deploy those files beneath the selected configuration file's canonical
directory. ConfigKit keeps imported and overlay files within that root;
Scriptorium does not widen it. Application path values inside those files still
resolve from the process working directory, so set that directory deliberately
in the service manager. This configuration-source boundary is distinct from
the HTTP artifact root and does not constrain prompt or schema directories.
When upgrading existing definitions, review selected profile IDs.
`deepseek-3-2`, `deepseek-4-flash`, and `deepseek-4-pro` were removed from the
selected catalog. Choose an available catalog profile, one of Promptkit's

View File

@@ -277,7 +277,11 @@ not yet advertised.
## Stage 4: Enable Explicit Imports And Selected Configuration Overlays
**Status: Not started.**
**Status: Complete.** File-backed loads support explicit imports and one
selected overlay under the selected file's canonical directory. All five
existing command modes accept `--config-overlay`; real-file composition and a
render workflow are tested. The composed example and contracts are updated;
full test, race, vet, build, and diff checks passed.
Primary files: shared loader, CLI source-selection wiring, configuration and
CLI tests, maintained configuration examples.

View File

@@ -0,0 +1,4 @@
backends:
local-gpu:
endpoint: http://localhost:11434/v1
api_key_env: LOCAL_GPU_API_KEY

View File

@@ -0,0 +1,3 @@
prompt_dir: ./examples/prompts
profile_dir: ./examples/profiles
schema_dir: ./examples/schemas

View File

@@ -0,0 +1,8 @@
# Select from the repository root; application paths remain working-directory-relative.
configkit:
imports:
- path: config-fragments/sources.yml
- path: config-fragments/backend.yml
server:
addr: :8080

View File

@@ -0,0 +1,4 @@
defaults:
render_format: json
server:
addr: 127.0.0.1:8080

View File

@@ -42,9 +42,9 @@ func applicationOverrides(fs *flag.FlagSet) []configkit.Override {
return overrides
}
func loadApplicationSettings(configPath string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) {
func loadApplicationSettings(configPath, overlay string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) {
result, err := appconfig.Load(appconfig.LoadOptions{
Path: configPath, Explicit: explicit, Overrides: overrides,
Path: configPath, Overlay: overlay, Explicit: explicit, Overrides: overrides,
})
if err != nil {
return appconfig.AppSettings{}, nil, err

View File

@@ -17,6 +17,7 @@ import (
type promptInspectionConfig struct {
configPath, promptDir, promptID, promptVersion, outputPath string
configOverlay string
configExplicit bool
outputFormat appformat.OutputFormat
overrides []configkit.Override
@@ -25,6 +26,7 @@ type promptInspectionConfig struct {
type profileInspectionConfig struct {
configPath, profileDir, profileID, outputPath string
configOverlay string
configExplicit bool
outputFormat appformat.OutputFormat
overrides []configkit.Override
@@ -127,7 +129,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error)
cfg := &profileInspectionConfig{outputFormat: appformat.DefaultOutputFormat}
fs := flag.NewFlagSet("inspect profile", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath)
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profiles")
fs.StringVar(&cfg.profileID, "profile", "", "profile ID to inspect")
rawFormat := ""
@@ -156,7 +158,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error)
}
func resolveAppSettingsForProfileInspection(cfg *profileInspectionConfig) (engineSettings, error) {
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides)
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configOverlay, cfg.configExplicit, cfg.overrides)
if err != nil {
return engineSettings{}, err
}
@@ -168,7 +170,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) {
cfg := &promptInspectionConfig{outputFormat: appformat.DefaultOutputFormat}
fs := flag.NewFlagSet("inspect prompt", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath)
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definitions")
fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to inspect")
fs.StringVar(&cfg.promptVersion, "prompt-version", "", "optional prompt version")
@@ -198,7 +200,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) {
}
func resolveAppSettingsForPromptInspection(cfg *promptInspectionConfig) (engineSettings, error) {
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides)
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configOverlay, cfg.configExplicit, cfg.overrides)
if err != nil {
return engineSettings{}, err
}

View File

@@ -59,6 +59,7 @@ type runConfig struct {
transientRetries int
configuredTransientRetries *int
diagnostics []configkit.Diagnostic
configOverlay string
defaultRenderFormat renderformat.PreparedRunOutputFormat
@@ -92,6 +93,7 @@ type serveConfig struct {
backends []appconfig.BackendSettings
transientRetries *int
diagnostics []configkit.Diagnostic
configOverlay string
}
type commonCommandSettings struct {
@@ -306,7 +308,7 @@ func parseRunArgs(args []string) (*runConfig, error) {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath)
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
registerExecutionRequestFlags(fs, cfg)
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
fs.DurationVar(&cfg.runTimeout, "run-timeout", 0, "whole-run execution deadline")
@@ -363,7 +365,7 @@ func parseRenderArgs(args []string) (*renderConfig, error) {
fs := flag.NewFlagSet("render", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath)
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
registerExecutionRequestFlags(fs, &cfg.runConfig)
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
@@ -394,7 +396,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerConfigPathFlag(fs, &cfg.configPath)
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
fs.StringVar(&cfg.addr, "addr", "", "HTTP listen address")
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
@@ -411,7 +413,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
}
settings, err := resolveCommonSettings(fs, cfg.configPath)
settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay)
if err != nil {
return nil, err
}
@@ -463,7 +465,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
return fmt.Errorf("unexpected positional args: %v", fs.Args())
}
settings, err := resolveCommonSettings(fs, cfg.configPath)
settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay)
if err != nil {
return err
}
@@ -500,7 +502,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
return nil
}
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
func registerConfigSelectionFlags(fs *flag.FlagSet, target, overlay *string) {
fs.StringVar(
target,
"config",
@@ -511,10 +513,11 @@ func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
appconfig.DefaultConfigPath,
),
)
fs.StringVar(overlay, "config-overlay", "", "selected application configuration overlay")
}
func resolveCommonSettings(fs *flag.FlagSet, configPath string) (commonCommandSettings, error) {
settings, diagnostics, err := loadApplicationSettings(configPath, flagWasSet(fs, "config"), applicationOverrides(fs))
func resolveCommonSettings(fs *flag.FlagSet, configPath, overlay string) (commonCommandSettings, error) {
settings, diagnostics, err := loadApplicationSettings(configPath, overlay, flagWasSet(fs, "config"), applicationOverrides(fs))
if err != nil {
return commonCommandSettings{}, err
}
@@ -750,9 +753,9 @@ func runErrorMessage(err error) string {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect> ...")
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]")
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]")
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--prompt-dir DIR] [--format text|json] [--out path]")
fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--profile-dir DIR] [--format text|json] [--out path]")
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]")
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]")
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--config-overlay NAME] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out path]")
fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out path]")
}

View File

@@ -1071,6 +1071,41 @@ profile_dir: %s
}
}
func TestRenderCommandLoadsImportsAndOverlayWithoutChangingExecutionProfile(t *testing.T) {
lib := newCLITestLibrary(t)
inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript")
writePromptFile(t, lib.promptDir, "prompt.render", "local-default")
writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
if err := os.MkdirAll(filepath.Join(lib.rootDir, "fragments"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(lib.rootDir, "overlays"), 0o755); err != nil {
t.Fatal(err)
}
base := filepath.Join(lib.rootDir, "config.yml")
for path, body := range map[string]string{
base: "configkit:\n imports:\n - path: fragments/sources.yml\n",
filepath.Join(lib.rootDir, "fragments", "sources.yml"): fmt.Sprintf("prompt_dir: %q\nprofile_dir: %q\n", lib.promptDir, lib.profileDir),
filepath.Join(lib.rootDir, "overlays", "local.yml"): "defaults:\n render_format: json\n",
} {
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
args := []string{"--config", base, "--config-overlay", "local", "--profile", "local-default", "--prompt", "prompt.render", "--input", "transcript=" + inputPath}
cfg, err := parseRenderArgs(args)
if err != nil {
t.Fatal(err)
}
if cfg.profileID != "local-default" || cfg.outputFormat != renderformat.PreparedRunFormatJSON {
t.Fatalf("overlay changed execution profile or output selection: %+v", cfg)
}
code, stdout, stderr := runCLICommand(t, renderCommand, args)
if code != ExitOK || !strings.Contains(stdout, `"selected_profile_id": "local-default"`) {
t.Fatalf("render did not use composed config and independent execution profile: code=%d out=%q err=%q", code, stdout, stderr)
}
}
func TestRenderCommandUsesConfiguredCustomBackend(t *testing.T) {
lib := newCLITestLibrary(t)
writePromptDefinition(t, lib.promptDir, "custom.yaml", `id: custom

View File

@@ -21,8 +21,10 @@ type LoadOptions struct {
// Load resolves one ConfigKit document and retains its inspection snapshot.
func Load(options LoadOptions) (*configkit.Result[Document], error) {
var source configkit.Source
fileBacked := false
if options.Explicit {
source = configkit.File(options.Path)
fileBacked = true
} else {
selected := ""
for _, candidate := range defaultConfigSearchPaths {
@@ -41,11 +43,14 @@ func Load(options LoadOptions) (*configkit.Result[Document], error) {
source = configkit.NoSource()
} else {
source = configkit.File(selected)
fileBacked = true
}
}
result, err := configkit.Load(source, configkit.Options[Document]{
Definition: documentDefinition(),
Imports: fileBacked,
Overlay: options.Overlay,
Overrides: options.Overrides,
})
if err != nil {

View File

@@ -174,3 +174,56 @@ func TestLoadDiscoveryErrorQuotesPath(t *testing.T) {
t.Fatalf("discovery error did not quote path safely: %v", err)
}
}
func TestLoadComposesExplicitImportsAndSelectedOverlay(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "fragments"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0o700); err != nil {
t.Fatal(err)
}
base := filepath.Join(dir, "config.yml")
writeApplicationFile(t, base, "configkit:\n imports:\n - glob: fragments/*.yml\nprompt_dir: ./base-prompts\nserver:\n addr: :8081\n")
writeApplicationFile(t, filepath.Join(dir, "fragments", "a.yml"), "backends:\n alpha:\n endpoint: http://alpha.invalid/v1\n")
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n beta:\n endpoint: http://beta.invalid/v1\n")
writeApplicationFile(t, filepath.Join(dir, "overlays", "local.yml"), "prompt_dir: ./overlay-prompts\nserver:\n addr: :9090\n")
result, err := Load(LoadOptions{Path: base, Explicit: true})
if err != nil || result.Config.PromptDir != "base-prompts" || len(result.Config.Backends) != 2 || len(result.Sources()) != 3 {
t.Fatalf("disjoint imports did not compose: result=%+v err=%v", result, err)
}
result, err = Load(LoadOptions{Path: base, Explicit: true, Overlay: "local", Overrides: []configkit.Override{{
Path: configkit.Keys("server", "addr"), Value: ":7070", Flag: "--addr",
}}})
if err != nil || result.Config.PromptDir != "overlay-prompts" || result.Config.Server.Addr != ":7070" || len(result.Sources()) != 5 {
t.Fatalf("overlay or explicit override changed: result=%+v err=%v", result, err)
}
if _, err := Load(LoadOptions{Path: base, Explicit: true, Overlay: "missing"}); err == nil {
t.Fatal("missing selected overlay accepted")
}
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n alpha:\n endpoint: http://conflict.invalid/v1\n")
if _, err := Load(LoadOptions{Path: base, Explicit: true}); err == nil {
t.Fatal("conflicting import accepted")
} else {
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("conflict lost structured diagnostic: %v", err)
}
}
}
func TestLoadRejectsOverlayWithoutBase(t *testing.T) {
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
if _, err := Load(LoadOptions{Overlay: "local"}); err == nil {
t.Fatal("overlay without base accepted")
} else {
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("no-base overlay lost structured diagnostic: %v", err)
}
}
if _, err := Load(LoadOptions{Overlay: "default"}); err != nil {
t.Fatalf("default must select no overlay: %v", err)
}
}