Introduce the version three configuration loader
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user