From 7c64592cab3ca49a9a04ac227e53031f03e1e846 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 10 Nov 2026 16:08:53 +0000 Subject: [PATCH] Introduce the version three configuration loader --- docs/roadmap/implementation.md | 6 + internal/cli/config_input.go | 53 ++-- internal/core/config/config.go | 113 ++++---- internal/core/config/config_test.go | 424 +++++++++++----------------- 4 files changed, 238 insertions(+), 358 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 95a0882..5359c02 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -77,6 +77,12 @@ callers identified. The assembled version and release checks belong to Stage 15. ## Stage 2: Define the v3 application schema and native loader +**Completed.** The native configuration result retains main source and context +evidence. Prompt loading, request clients, command routing, glossary composition, +resolved stage defaults, and processing callers still require the migrations +assigned below; existing unconverted callers and tests may not compile. Final +examples and assembled validation remain assigned later in this plan. + Read the [ConfigKit outline](configkit.md) and glossary [scope inventory](glossary-stages.md#configuration-scope--resolved). Own `internal/core/config` and the application-loading portion of diff --git a/internal/cli/config_input.go b/internal/cli/config_input.go index 444535a..bb34897 100644 --- a/internal/cli/config_input.go +++ b/internal/cli/config_input.go @@ -5,17 +5,14 @@ import ( "flag" "fmt" "io" - "os" "path/filepath" "strconv" "strings" - ck "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/audita/v3/internal/core/config" + ck "go.maximumdirect.net/configkit" ) -var systemConfigPaths = []string{"/usr/local/etc/audita/config.yml", "/etc/audita/config.yml"} - type configInput struct { ConfigPath string ConfigPathSet bool @@ -162,39 +159,33 @@ func parseConfigInput(command string, args []string, output io.Writer) (configIn return input, false, nil } -func selectConfigPath(explicit string, set bool, stat func(string) (os.FileInfo, error), candidates []string) (string, string, error) { - if set { - if strings.TrimSpace(explicit) == "" { - return "", "", fmt.Errorf("--config requires a nonempty path") - } - if _, err := stat(explicit); err != nil { - return "", "", fmt.Errorf("config file %q: %w", explicit, err) - } - return explicit, "flag", nil - } - for _, path := range candidates { - if _, err := stat(path); err == nil { - return path, "default", nil - } else if !os.IsNotExist(err) { - return "", "", fmt.Errorf("config file %q: %w", path, err) - } - } - return "", "", fmt.Errorf("no configuration file found; select a config with backend and profile settings") -} - func loadConfigInput(input configInput, command string) (selectedConfig, error) { - if command == "validate" && (!input.ConfigPathSet || strings.TrimSpace(input.ConfigPath) == "") { - return selectedConfig{}, fmt.Errorf("--config is required") + var source ck.Source + if input.ConfigPathSet { + if strings.TrimSpace(input.ConfigPath) == "" { + return selectedConfig{}, fmt.Errorf("--config requires a nonempty path") + } + source = ck.File(input.ConfigPath) + } else { + source = ck.SearchConfig("audita", ck.Searches(ck.WorkingDirectory(), ck.UserConfigDirectory(), ck.UnixSystemDirectories())) } - path, source, err := selectConfigPath(input.ConfigPath, input.ConfigPathSet, os.Stat, systemConfigPaths) + loaded, err := config.Load(ck.Input{Source: source}, input.Overlay, input.Overrides...) if err != nil { return selectedConfig{}, err } - loaded, err := config.Load(ck.File(path), input.Overlay, input.Overrides...) - if err != nil { - return selectedConfig{}, err + selected := selectedConfig{Result: loaded} + for _, info := range loaded.Sources() { + if info.Layer == ck.LayerMain && info.ImportedFrom == nil { + selected.Path = info.Path + if input.ConfigPathSet { + selected.Source = "flag" + } else { + selected.Source = "default" + } + break + } } - return selectedConfig{Path: path, Source: source, Result: loaded}, nil + return selected, nil } func configInputError(err error) string { diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 603b05d..4a932e3 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -5,9 +5,9 @@ import ( "strings" "time" - ck "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/audita/v3/internal/core/modulecatalog" "gitea.maximumdirect.net/eric/audita/v3/internal/core/outputschema" + ck "go.maximumdirect.net/configkit" ) const ( @@ -16,38 +16,55 @@ const ( DefaultValidationMaxPromptTokens = 2048 ) -// Config is the version-2 application configuration. Invocation paths are not settings. +// Config contains application settings. Invocation paths are not settings. type Config struct { - Version int `yaml:"version"` - ProfilesDir string `yaml:"profiles_dir"` - Backends map[string]Backend `yaml:"backends"` - LLM LLM `yaml:"llm"` - Pipeline Pipeline `yaml:"pipeline"` - Output Output `yaml:"output"` - Validation Validation `yaml:"validation"` - Chunking Chunking `yaml:"chunking"` - Normalization Normalization `yaml:"normalization"` - Thresholds Thresholds `yaml:"thresholds"` - Context Context `yaml:"context"` - Reporting Reporting `yaml:"reporting"` - Diagnostics Diagnostics `yaml:"diagnostics"` + Version int `yaml:"version"` + Glossaries []string `yaml:"glossaries"` + ProtectedTerms []string `yaml:"protected_terms"` + GlossaryCollections map[string]GlossaryCollection `yaml:"glossary_collections"` + LLM LLM `yaml:"llm"` + Pipeline Pipeline `yaml:"pipeline"` + Output Output `yaml:"output"` + Validation Validation `yaml:"validation"` + Chunking Chunking `yaml:"chunking"` + Normalization Normalization `yaml:"normalization"` + Thresholds Thresholds `yaml:"thresholds"` + Context Context `yaml:"context"` + Reporting Reporting `yaml:"reporting"` + Diagnostics Diagnostics `yaml:"diagnostics"` } -type Backend struct { - Endpoint string `yaml:"endpoint"` - Concurrency int `yaml:"concurrency"` - QueueCapacity int `yaml:"queue_capacity"` - Credential *ck.Secret `yaml:"credential"` +type GlossaryCollection struct { + Entries []GlossaryEntry `yaml:"entries"` +} +type GlossaryEntry struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Category string `yaml:"category"` + Description string `yaml:"description"` + Aliases []string `yaml:"aliases"` + Plural *string `yaml:"plural"` + Extra map[string]any `yaml:",inline"` } type LLM struct { - ProposalProfile string `yaml:"proposal_profile"` - ValidationProfile string `yaml:"validation_profile"` + ProposalProfile *string `yaml:"proposal_profile"` + ValidationProfile *string `yaml:"validation_profile"` TransientRetries int `yaml:"transient_retries"` RepairAttempts int `yaml:"repair_attempts"` OperationTimeout time.Duration `yaml:"operation_timeout"` } type Pipeline struct { - Modules []string `yaml:"modules"` + Stages []Stage `yaml:"stages"` +} +type Stage struct { + Module string `yaml:"module"` + ID *string `yaml:"id"` + Glossaries []string `yaml:"glossaries"` + ProtectedTerms []string `yaml:"protected_terms"` + ConfidenceThreshold float64 `yaml:"confidence_threshold"` + LLM LLM `yaml:"llm"` + Validation Validation `yaml:"validation"` + Context Context `yaml:"context"` } type Output struct { Schema string `yaml:"schema"` @@ -76,7 +93,6 @@ type Context struct { Description string `yaml:"description"` } type Diagnostics struct { - Debug bool `yaml:"debug"` WorkDir string `yaml:"work_dir"` } type Reporting struct { @@ -86,9 +102,9 @@ type Reporting struct { func defaults() Config { return Config{ - ProfilesDir: "profiles", + Version: 3, LLM: LLM{TransientRetries: 2, RepairAttempts: 2, OperationTimeout: 15 * time.Minute}, - Pipeline: Pipeline{Modules: []string{"glossary", "homophones", "glossary", "spoken_word", "grammar"}}, + Pipeline: Pipeline{Stages: []Stage{{Module: "glossary"}, {Module: "homophones"}, {Module: "glossary"}, {Module: "spoken_word"}, {Module: "grammar"}}}, Output: Output{Schema: "bare-segments"}, Validation: Validation{MaxPromptTokens: DefaultValidationMaxPromptTokens}, Chunking: Chunking{MaxSectionTokens: 8192, MinSectionTokens: 2048}, @@ -99,38 +115,19 @@ func defaults() Config { } } -// Load resolves the selected main file, optional overlay, and explicit setting overrides. -// Its result retains safe effective output and provenance for CLI composition. -func Load(source ck.Source, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) { - return ck.Load(source, ck.Options[Config]{ +// Load resolves the selected main source and preserves native source evidence. +func Load(input ck.Input, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) { + input.Imports = true + return ck.LoadLayered(input, ck.LayeredOptions[Config]{ Overlay: overlay, Overrides: overrides, Definition: ck.Definition[Config]{ Defaults: defaults, Version: &ck.VersionRule{Path: ck.Keys("version"), Check: func(version ck.Version, v *ck.Validation) error { - if !version.Present || version.Null || version.Text != "2" { - v.Error(ck.Keys("version"), "version 2 is required; see docs/migrations/v2.md") + if version.Present && (version.Null || version.Text != "3") { + v.Error(ck.Keys("version"), "use application configuration version 3; see docs/migrations/v3.md") } return nil }}, - Resolve: func(c *Config, r *ck.Resolution) error { - ck.Default(r, ck.Keys("llm", "validation_profile"), &c.LLM.ValidationProfile, c.LLM.ProposalProfile, "validation uses the final proposal profile when omitted", ck.Keys("llm", "proposal_profile")) - for id, backend := range c.Backends { - changed := false - if !r.IsExplicit(ck.Keys("backends", id, "concurrency")) { - backend.Concurrency = 1 - changed = true - } - if !r.IsExplicit(ck.Keys("backends", id, "queue_capacity")) { - backend.QueueCapacity = 1024 - changed = true - } - if changed { - c.Backends[id] = backend - r.Record(ck.Keys("backends", id), ck.ChangeDerivedDefault, "finite backend defaults") - } - } - return nil - }, Validate: validate, }, }) @@ -142,21 +139,11 @@ func validate(c *Config, v *ck.Validation) { v.Error(path, message) } } - check(strings.TrimSpace(c.ProfilesDir) != "", ck.Keys("profiles_dir"), "profile directory is required") - check(strings.TrimSpace(c.LLM.ProposalProfile) != "", ck.Keys("llm", "proposal_profile"), "proposal profile is required") - check(strings.TrimSpace(c.LLM.ValidationProfile) != "", ck.Keys("llm", "validation_profile"), "validation profile is required") check(c.LLM.TransientRetries >= 0, ck.Keys("llm", "transient_retries"), "retry budget must be non-negative") check(c.LLM.RepairAttempts >= 0 && c.LLM.RepairAttempts <= 3, ck.Keys("llm", "repair_attempts"), "repair attempts must be between zero and three") check(c.LLM.OperationTimeout > 0, ck.Keys("llm", "operation_timeout"), "operation timeout must be positive") - check(len(c.Backends) > 0, ck.Keys("backends"), "at least one backend is required") - for id, b := range c.Backends { - p := ck.Keys("backends", id) - check(strings.TrimSpace(b.Endpoint) != "", p.Key("endpoint"), "backend endpoint is required") - check(b.Concurrency > 0, p.Key("concurrency"), "backend concurrency must be positive") - check(b.QueueCapacity >= 0, p.Key("queue_capacity"), "queue capacity must be non-negative") - } - for i, module := range c.Pipeline.Modules { - check(modulecatalog.IsSupported(module), ck.Keys("pipeline", "modules").Index(i), "unsupported module key") + for i, stage := range c.Pipeline.Stages { + check(modulecatalog.IsSupported(stage.Module), ck.Keys("pipeline", "stages").Index(i).Key("module"), "unsupported module key") } check(outputschema.IsSupported(c.Output.Schema), ck.Keys("output", "schema"), "unsupported output schema") check(c.Validation.MaxPromptTokens > 0, ck.Keys("validation", "max_prompt_tokens"), "validation prompt budget must be positive") diff --git a/internal/core/config/config_test.go b/internal/core/config/config_test.go index 9b07f58..1e32e43 100644 --- a/internal/core/config/config_test.go +++ b/internal/core/config/config_test.go @@ -1,303 +1,199 @@ package config import ( - "bytes" - "errors" + "io/fs" "os" + "path" "path/filepath" - "reflect" - "strings" "testing" - ck "gitea.maximumdirect.net/eric/ConfigKit" + ck "go.maximumdirect.net/configkit" ) -const base = "version: 2\nbackends:\n local:\n endpoint: https://local.invalid/v1\nllm:\n proposal_profile: proposal\n" - -func loadText(text, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) { - return Load(ck.Bytes("config.yml", []byte(text)), overlay, overrides...) -} - -func TestDefaultsAndDomainSettings(t *testing.T) { - loaded, err := loadText(base, "") +func TestDefaultsAndVersion(t *testing.T) { + result, err := Load(ck.Input{Source: ck.NoSource()}, "") if err != nil { t.Fatal(err) } - c := loaded.Config - if c.LLM.ValidationProfile != "proposal" || c.Backends["local"].Concurrency != 1 || c.Backends["local"].QueueCapacity != 1024 { - t.Fatalf("derived settings: %+v %+v", c.LLM, c.Backends) + if result.Config.Version != 3 || len(result.Config.Pipeline.Stages) != 5 { + t.Fatalf("defaults: %+v", result.Config) } - if !reflect.DeepEqual(c.Pipeline.Modules, []string{"glossary", "homophones", "glossary", "spoken_word", "grammar"}) { - t.Fatalf("module order: %v", c.Pipeline.Modules) - } - if explanation, ok := loaded.Explain(ck.Keys("llm", "validation_profile")); !ok || len(explanation.History) == 0 { - t.Fatal("validation inheritance has no recorded derivation") - } - if _, err := loadText(base+"pipeline:\n modules: []\n", ""); err != nil { - t.Fatalf("empty pipeline: %v", err) - } - if _, err := loadText(base+"pipeline:\n modules: [grammar, glossary, grammar]\n", ""); err != nil { - t.Fatalf("repeated modules: %v", err) - } -} - -func TestStrictInputAndConstraints(t *testing.T) { - for _, tc := range []struct{ name, text string }{ - {"old version", strings.Replace(base, "version: 2", "version: 1", 1)}, - {"missing version", strings.Replace(base, "version: 2\n", "", 1)}, - {"null version", strings.Replace(base, "version: 2", "version: null", 1)}, - {"quoted version", strings.Replace(base, "version: 2", "version: '2'", 1)}, - {"unknown field", base + "mystery: true\n"}, - {"empty validation profile", base + " validation_profile: ''\n"}, - {"null validation profile", base + " validation_profile: null\n"}, - {"null proposal profile", strings.Replace(base, "proposal_profile: proposal", "proposal_profile: null", 1)}, - {"zero backend concurrency", base + " concurrency: 0\n"}, - {"invalid target", base + "chunking:\n target_sections: 0\n"}, - {"invalid section relation", base + "chunking:\n min_section_tokens: 10000\n"}, - {"string normalization", base + "normalization:\n max_segment_gap: 4s\n"}, - {"invalid threshold", base + "thresholds:\n glossary: 1.1\n"}, - {"removed retention", base + "diagnostics:\n retention: always\n"}, - } { - t.Run(tc.name, func(t *testing.T) { - if _, err := loadText(tc.text, ""); err == nil { - t.Fatal("invalid config accepted") - } - }) - } - _, err := loadText(strings.Replace(base, "version: 2", "version: 1", 1), "") - var loadErr *ck.LoadError - if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || !strings.Contains(loadErr.Diagnostics()[0].Message, "docs/migrations/v2.md") { - t.Fatalf("migration guidance: %v", err) - } - for _, tc := range []struct{ name, text string }{ - {"optional target null", base + "chunking:\n target_sections: null\n"}, - {"zero queue", strings.Replace(base, "llm:\n", " queue_capacity: 0\nllm:\n", 1)}, - {"zero retries", base + " transient_retries: 0\n repair_attempts: 0\n"}, - {"zero threshold", base + "thresholds:\n grammar: 0\n"}, - } { - t.Run(tc.name, func(t *testing.T) { - if _, err := loadText(tc.text, ""); err != nil { - t.Fatal(err) - } - }) - } -} - -func TestOverlayOverrideAndInheritance(t *testing.T) { - dir := t.TempDir() - if err := os.Mkdir(filepath.Join(dir, "overlays"), 0700); err != nil { - t.Fatal(err) - } - main := filepath.Join(dir, "config.yml") - if err := os.WriteFile(main, []byte(base), 0600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "overlays", "testing.yml"), []byte("llm:\n proposal_profile: testing\nchunking:\n max_section_tokens: 1000\n"), 0600); err != nil { - t.Fatal(err) - } - loaded, err := Load(ck.File(main), "testing", ck.Override{Path: ck.Keys("llm", "proposal_profile"), Value: "explicit", Flag: "--proposal-profile"}, ck.Override{Path: ck.Keys("chunking", "min_section_tokens"), Value: 500, Flag: "--min-section-tokens"}) - if err != nil { - t.Fatal(err) - } - if loaded.Config.LLM.ProposalProfile != "explicit" || loaded.Config.LLM.ValidationProfile != "explicit" || loaded.Config.Chunking.MaxSectionTokens != 1000 || loaded.Config.Chunking.MinSectionTokens != 500 { - t.Fatalf("resolved: %+v", loaded.Config) - } - if got, _ := loaded.Metadata().Lookup(ck.Keys("llm", "proposal_profile")); got.Origin.Layer != ck.LayerCLI { - t.Fatalf("override origin: %+v", got) - } - if got, _ := loaded.Metadata().Lookup(ck.Keys("chunking", "max_section_tokens")); got.Origin.Layer != ck.LayerOverlay { - t.Fatalf("overlay origin: %+v", got) - } -} - -func TestSurvivingCredentials(t *testing.T) { - t.Setenv("BASE_SECRET", "") - t.Setenv("NEW_SECRET", "synthetic-secret") - withSecret := strings.Replace(base, "llm:\n", " other:\n endpoint: https://other.invalid/v1\n credential: BASE_SECRET\nllm:\n", 1) - if _, err := loadText(withSecret, ""); err == nil { - t.Fatal("missing unselected backend secret accepted") - } - replacement := ck.Override{Path: ck.Keys("backends", "other", "credential"), Value: "NEW_SECRET", Flag: "--credential"} - loaded, err := loadText(withSecret, "", replacement) - if err != nil { - t.Fatal(err) - } - if got := loaded.Config.Backends["other"].Credential.Value(); got != "synthetic-secret" { - t.Fatalf("resolved secret: %q", got) - } - var effective bytes.Buffer - if err := loaded.WriteEffective(&effective); err != nil { - t.Fatal(err) - } - if strings.Contains(effective.String(), "synthetic-secret") { - t.Fatal("effective output leaked a secret") - } - null := ck.Override{Path: ck.Keys("backends", "other", "credential"), Value: nil, Flag: "--credential"} - loaded, err = loadText(withSecret, "", null) - if err != nil || loaded.Config.Backends["other"].Credential != nil { - t.Fatalf("null credential: %+v %v", loaded, err) - } - - inherited := `version: 2 -backends: - parent: - endpoint: https://parent.invalid/v1 - credential: BASE_SECRET - local: - extends: parent - credential: null -llm: - proposal_profile: proposal -` - if _, err := loadText(inherited, ""); err == nil { - t.Fatal("null child credential removed the unselected parent's surviving reference") - } - t.Setenv("BASE_SECRET", "synthetic-parent-secret") - loaded, err = loadText(strings.Replace(inherited, " credential: null\n", "", 1), "") - if err != nil { - t.Fatal(err) - } - if got := loaded.Config.Backends["local"].Credential.Value(); got != "synthetic-parent-secret" { - t.Fatalf("inherited secret: %q", got) - } - if got := loaded.Metadata().IsExplicit(ck.Keys("backends", "local", "credential")); !got { - t.Fatal("inherited credential is not explicit") - } - var inheritedEffective bytes.Buffer - if err := loaded.WriteEffective(&inheritedEffective); err != nil { - t.Fatal(err) - } - if strings.Contains(inheritedEffective.String(), "synthetic-parent-secret") { - t.Fatal("effective output leaked an inherited secret") - } - loaded, err = loadText(inherited, "") - if err != nil || loaded.Config.Backends["local"].Credential != nil { - t.Fatalf("child null credential: %+v %v", loaded, err) - } - if got := loaded.Config.Backends["parent"].Credential.Value(); got != "synthetic-parent-secret" { - t.Fatalf("parent credential after child null: %q", got) - } - - t.Setenv("AUDITA_MODEL", "legacy-model") - loaded, err = loadText(base, "") - if err != nil || loaded.Config.LLM.ProposalProfile != "proposal" { - t.Fatalf("legacy environment affected loading: %+v %v", loaded, err) - } -} - -func TestSiblingInheritancePreservesBackendSettings(t *testing.T) { - text := `version: 2 -backends: - base: - endpoint: https://base.invalid/v1 - concurrency: 3 - queue_capacity: 0 - local: - extends: base -llm: - proposal_profile: proposal -` - loaded, err := loadText(text, "") - if err != nil { - t.Fatal(err) - } - backend := loaded.Config.Backends["local"] - if backend.Endpoint != "https://base.invalid/v1" || backend.Concurrency != 3 || backend.QueueCapacity != 0 { - t.Fatalf("inherited backend settings: %+v", backend) - } - for _, path := range []ck.Path{ - ck.Keys("backends", "local", "endpoint"), - ck.Keys("backends", "local", "concurrency"), - ck.Keys("backends", "local", "queue_capacity"), - } { - if !loaded.Metadata().IsExplicit(path) { - t.Errorf("inherited field %s is not explicit", path.String()) + path := filepath.Join(t.TempDir(), "config.yml") + for _, text := range []string{"version: 2\n", "version: null\n", "version: invalid\n"} { + if err := os.WriteFile(path, []byte(text), 0644); err != nil { + t.Fatal(err) + } + if _, err := Load(ck.Input{Source: ck.File(path)}, ""); err == nil { + t.Fatalf("accepted %q", text) } } - - overridden, err := loadText(strings.Replace(text, " extends: base\n", " extends: base\n concurrency: 2\n", 1), "") + if err := os.WriteFile(path, []byte("pipeline:\n stages: []\n"), 0644); err != nil { + t.Fatal(err) + } + empty, err := Load(ck.Input{Source: ck.File(path)}, "") if err != nil { t.Fatal(err) } - if got := overridden.Config.Backends["local"].Concurrency; got != 2 { - t.Fatalf("child concurrency override: %d", got) - } - if got := overridden.Config.Backends["local"].QueueCapacity; got != 0 { - t.Fatalf("inherited zero queue capacity: %d", got) + if len(empty.Config.Pipeline.Stages) != 0 { + t.Fatal("empty pipeline was not preserved") } + } -func TestSiblingInheritanceUsesSelectedOverlay(t *testing.T) { +func TestMainContextImportsAndOverlay(t *testing.T) { dir := t.TempDir() - if err := os.Mkdir(filepath.Join(dir, "overlays"), 0700); err != nil { + if err := os.Mkdir(filepath.Join(dir, "overlays"), 0755); err != nil { t.Fatal(err) } - main := filepath.Join(dir, "config.yml") - text := `version: 2 -backends: - base: - endpoint: https://base.invalid/v1 - concurrency: 3 - queue_capacity: 0 - local: - extends: base - concurrency: 2 -llm: - proposal_profile: proposal -` - if err := os.WriteFile(main, []byte(text), 0600); err != nil { - t.Fatal(err) + for name, contents := range map[string]string{ + "main.yml": "version: 3\nconfig:\n imports:\n - path: fragment.yml\noutput:\n schema: bare-segments\n", + "fragment.yml": "context:\n description: imported\n", + "overlays/selected.yml": "context:\n description: overlay\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } } - if err := os.WriteFile(filepath.Join(dir, "overlays", "testing.yml"), []byte("backends:\n base:\n endpoint: https://overlay.invalid/v1\n concurrency: 4\n"), 0600); err != nil { - t.Fatal(err) - } - loaded, err := Load(ck.File(main), "testing") + result, err := Load(ck.Input{Source: ck.File(filepath.Join(dir, "main.yml"))}, "selected", ck.Override{Path: ck.Keys("context", "description"), Value: "override", Flag: "--description"}) if err != nil { t.Fatal(err) } - child := loaded.Config.Backends["local"] - if child.Endpoint != "https://overlay.invalid/v1" || child.Concurrency != 2 || child.QueueCapacity != 0 { - t.Fatalf("overlay inheritance and child override: %+v", child) + if info, ok := result.Metadata().Lookup(ck.Keys("context", "description")); !ok || info.Origin.Layer != ck.LayerCLI { + t.Fatalf("override origin = %+v", info) + } + if result.Config.Context.Description != "override" { + t.Fatalf("description = %q", result.Config.Context.Description) + } + found := false + for _, source := range result.Sources() { + if source.Layer == ck.LayerMain && source.ImportedFrom == nil { + context, ok := result.SourceContext(source.ID) + if !ok { + t.Fatal("main context missing") + } + data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, "fragment.yml")) + if err != nil || len(data) == 0 { + t.Fatalf("main context cannot access companion: %v", err) + } + found = true + } + } + if !found { + t.Fatal("main source missing") + } + if _, err := Load(ck.Input{Source: ck.File(filepath.Join(dir, "main.yml"))}, "missing"); err == nil { + t.Fatal("missing overlay accepted") + } + if _, err := Load(ck.Input{Source: ck.NoSource()}, "selected"); err == nil { + t.Fatal("overlay without context accepted") } } -func TestSiblingInheritanceDoesNotBypassBackendValidation(t *testing.T) { +func TestDiscoveryAndSelectedFailure(t *testing.T) { + first, second := t.TempDir(), t.TempDir() + search := ck.Searches(ck.SearchDirectory(first), ck.SearchDirectory(second)) + load := func() (*ck.Result[Config], error) { + return Load(ck.Input{Source: ck.SearchConfig("audita", search)}, "") + } + result, err := load() + if err != nil { + t.Fatal(err) + } + for _, source := range result.Sources() { + if source.Layer == ck.LayerMain { + t.Fatalf("unexpected main source: %+v", source) + } + } + if err := os.WriteFile(filepath.Join(second, "config.yml"), []byte("context:\n description: selected\n"), 0644); err != nil { + t.Fatal(err) + } + result, err = load() + if err != nil { + t.Fatal(err) + } + if result.Config.Context.Description != "selected" { + t.Fatalf("description = %q", result.Config.Context.Description) + } + if err := os.WriteFile(filepath.Join(first, "config.yml"), []byte("version: 2\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := load(); err == nil { + t.Fatal("invalid selected file fell through") + } +} + +func TestOverlayUsesMainDirectoryWithinWiderRoot(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "project") + if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte("version: 3\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "overlays", "local.yml"), []byte("context:\n description: project\n"), 0644); err != nil { + t.Fatal(err) + } + result, err := Load(ck.Input{Source: ck.File(filepath.Join(dir, "config.yml")), Filesystem: &ck.FilesystemContext{Root: root}}, "local") + if err != nil { + t.Fatal(err) + } + if result.Config.Context.Description != "project" { + t.Fatalf("description = %q", result.Config.Context.Description) + } +} + +func TestSchemaConstraintsAndExplicitValues(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") for _, tc := range []struct { - name string - text string + name, text string + valid bool }{ - { - name: "invalid inherited parent setting", - text: `version: 2 -backends: - base: - endpoint: https://base.invalid/v1 - concurrency: 0 - local: - extends: base -llm: - proposal_profile: proposal -`, - }, - { - name: "invalid child override", - text: `version: 2 -backends: - base: - endpoint: https://base.invalid/v1 - concurrency: 3 - local: - extends: base - concurrency: 0 -llm: - proposal_profile: proposal -`, - }, + {"nullable selections", "glossaries: null\nprotected_terms: []\nllm:\n validation_profile: null\n", true}, + {"repeated modules", "pipeline:\n stages:\n - module: grammar\n - module: grammar\n", true}, + {"removed backends", "backends: {}\n", false}, + {"removed debug", "diagnostics:\n debug: true\n", false}, + {"unknown module", "pipeline:\n stages:\n - module: unknown\n", false}, + {"invalid chunk limit", "chunking:\n target_sections: 0\n", false}, + {"invalid threshold", "thresholds:\n grammar: 1.1\n", false}, + {"null group", "llm: null\n", false}, + {"zero budgets", "llm:\n transient_retries: 0\n repair_attempts: 0\n", true}, } { t.Run(tc.name, func(t *testing.T) { - if _, err := loadText(tc.text, ""); err == nil { - t.Fatal("invalid backend concurrency accepted") + if err := os.WriteFile(path, []byte(tc.text), 0644); err != nil { + t.Fatal(err) + } + _, err := Load(ck.Input{Source: ck.File(path)}, "") + if (err == nil) != tc.valid { + t.Fatalf("load error: %v", err) } }) } } + +func TestOverridesFollowInheritance(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + data := `glossary_collections: + common: + entries: + - id: a + name: First + category: character + derived: + extends: common +` + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatal(err) + } + result, err := Load(ck.Input{Source: ck.File(path)}, "", ck.Override{Path: ck.Keys("glossary_collections", "common", "entries"), Value: []any{map[string]any{"id": "a", "name": "Changed", "category": "character"}}, Flag: "--set"}) + if err != nil { + t.Fatalf("%v: %+v", err, err.(*ck.LoadError).Diagnostics()) + } + if got := result.Config.GlossaryCollections["derived"].Entries[0].Name; got != "First" { + t.Fatalf("inherited name = %q", got) + } + if got := result.Config.GlossaryCollections["common"].Entries[0].Name; got != "Changed" { + t.Fatalf("overridden name = %q", got) + } +}