diff --git a/CHANGELOG.md b/CHANGELOG.md index f17220f..0dfc715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project follows Semantic Versioning. ## [Unreleased] +### Changed + +- **Breaking:** replaced legacy per-setting `.code-converge/` files with strict project and user `config.yaml` documents. The next release must be a SemVer major release. + ## [0.7.0] - 2026-07-30 ### Added diff --git a/README.md b/README.md index 08f1358..e9898e1 100644 --- a/README.md +++ b/README.md @@ -296,13 +296,13 @@ Agent output is sanitized before rendering: terminal controls are removed, inval ## Configuration -Every option can be supplied in four places: a command-line flag, an environment variable, project configuration, or user configuration. +Every option can be supplied in four places: a command-line flag, an environment variable, project YAML, or user YAML. Resolution order is highest to lowest priority: 1. Command-line flags -2. Project configuration in `/.code-converge/` -3. User configuration in `~/.code-converge/` +2. Project configuration in `/.code-converge/config.yaml` +3. User configuration in `~/.code-converge/config.yaml` 4. Environment variables 5. Built-in defaults @@ -322,9 +322,11 @@ The `fast` and `best` modes select these operative stage profiles. `fast` is the | Fix findings | `gpt-5.6-luna`, `medium` | `gpt-5.6-terra`, `high` | Findings involve architecture, security, migrations, concurrency, or several connected modules. | | Fix CI | `gpt-5.6-luna`, `medium` | `gpt-5.6-terra`, `high` | The cause is not localized by logs, spans multiple components, or persists after a repair. | +`config.yaml` is the only file-based configuration source. It is a strict, flat YAML mapping: unknown, duplicate, nested, malformed, and invalid values are rejected. Legacy per-setting files are ignored without migration or fallback. Prompt values remain file references; relative paths resolve from the directory containing the YAML file. + ### Options and defaults -| Option | Flag | Environment variable | Project / user file | Default | +| Option | Flag | Environment variable | YAML key | Default | | --- | --- | --- | --- | --- | | Workflow log format | `--log-format` | `CODE_CONVERGE_LOG_FORMAT` | `log-format` | `human` | | Human liveness heartbeat | `--heartbeat` | `CODE_CONVERGE_HEARTBEAT` | `heartbeat` | `0` (disabled) | @@ -337,43 +339,38 @@ The `fast` and `best` modes select these operative stage profiles. `fast` is the | Review reasoning effort | `--review-reasoning-effort` | `CODE_CONVERGE_REVIEW_REASONING_EFFORT` | `review-reasoning-effort` | selected profile | | Fix-findings model | `--fix-model` | `CODE_CONVERGE_FIX_MODEL` | `fix-model` | selected profile | | Fix-findings reasoning effort | `--fix-reasoning-effort` | `CODE_CONVERGE_FIX_REASONING_EFFORT` | `fix-reasoning-effort` | selected profile | -| Fix-findings prompt | `--fix-prompt-file` | `CODE_CONVERGE_FIX_PROMPT_FILE` | `fix-findings.md` | `fix findings` | +| Fix-findings prompt | `--fix-prompt-file` | `CODE_CONVERGE_FIX_PROMPT_FILE` | `fix-prompt-file` | `fix findings` | | CI-fix model | `--ci-fix-model` | `CODE_CONVERGE_CI_FIX_MODEL` | `ci-fix-model` | selected profile | | CI-fix reasoning effort | `--ci-fix-reasoning-effort` | `CODE_CONVERGE_CI_FIX_REASONING_EFFORT` | `ci-fix-reasoning-effort` | selected profile | -| CI-fix prompt | `--ci-fix-prompt-file` | `CODE_CONVERGE_CI_FIX_PROMPT_FILE` | `fix-ci.md` | `Исправь CI` | +| CI-fix prompt | `--ci-fix-prompt-file` | `CODE_CONVERGE_CI_FIX_PROMPT_FILE` | `ci-fix-prompt-file` | `Исправь CI` | | Review base override | `--review-base` | `CODE_CONVERGE_REVIEW_BASE` | `review-base` | discover intended base | | Diagnostic session-log directory | `--session-log-dir` | `CODE_CONVERGE_SESSION_LOG_DIR` | `session-log-dir` | `~/.code-converge/session-logs` | | Diagnostic session-log retention | `--session-log-retention` | `CODE_CONVERGE_SESSION_LOG_RETENTION` | `session-log-retention` | `24h` | | Disable diagnostic logging for this run | `--no-session-log` | — | — | disabled only when flag supplied | -`--finalize-model`, `--finalize-reasoning-effort`, and `--finalize-prompt-file`, their `CODE_CONVERGE_FINALIZE_*` environment variables, and `finalize-*` / `finalize.md` configuration files were removed in this release. Remove them during migration: obsolete environment or configuration-file settings cause an actionable configuration error rather than being ignored, because Codex no longer performs publication or CI polling. +`--finalize-model`, `--finalize-reasoning-effort`, and `--finalize-prompt-file`, their `CODE_CONVERGE_FINALIZE_*` environment variables, and their YAML keys were removed in this release because Codex no longer performs publication or CI polling. Obsolete environment settings and YAML keys cause actionable configuration errors. -For example, a team can commit these files: +For example, a team can commit one project configuration document and keep prompt content alongside it: ```text .code-converge/ -├── log-format -├── heartbeat -├── color -├── mode -├── review-model -├── review-reasoning-effort -├── review-base -├── fix-model -├── fix-reasoning-effort -├── ci-fix-model -├── ci-fix-reasoning-effort -├── max-cycles -├── max-ci-recoveries - -├── ci-timeout -├── session-log-dir -├── session-log-retention -├── fix-findings.md -└── fix-ci.md +├── config.yaml +└── prompts/ + ├── fix-findings.md + └── fix-ci.md +``` + +```yaml +# /.code-converge/config.yaml +mode: best +max-cycles: 3 +ci-timeout: 45m +review-base: main +fix-prompt-file: prompts/fix-findings.md +ci-fix-prompt-file: prompts/fix-ci.md ``` -The same layout in `~/.code-converge/` sets user-level defaults. Environment variables are particularly useful in CI or temporary shell sessions: +The same schema in `~/.code-converge/config.yaml` sets user-level defaults. Environment variables are particularly useful in CI or temporary shell sessions: ```sh CODE_CONVERGE_MAX_CYCLES=3 \ diff --git a/go.mod b/go.mod index 2246f7e..37339f1 100644 --- a/go.mod +++ b/go.mod @@ -9,3 +9,5 @@ require ( ) require golang.org/x/sys v0.21.0 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index aeb00e0..c2cc13a 100644 --- a/go.sum +++ b/go.sum @@ -6,3 +6,7 @@ golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 314da7c..103c96a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,12 +2,15 @@ package config import ( "fmt" + "io" "os" "os/exec" "path/filepath" "strconv" "strings" "time" + + "gopkg.in/yaml.v3" ) const ( @@ -91,6 +94,29 @@ type spec struct { promptFile bool } +type fileConfig map[string]string + +type yamlFileConfig struct { + LogFormat *string `yaml:"log-format"` + Heartbeat *string `yaml:"heartbeat"` + Color *string `yaml:"color"` + Mode *string `yaml:"mode"` + MaxCycles *int `yaml:"max-cycles"` + MaxCIRecoveries *int `yaml:"max-ci-recoveries"` + CITimeout *string `yaml:"ci-timeout"` + ReviewModel *string `yaml:"review-model"` + ReviewEffort *string `yaml:"review-reasoning-effort"` + FixModel *string `yaml:"fix-model"` + FixEffort *string `yaml:"fix-reasoning-effort"` + FixPromptPath *string `yaml:"fix-prompt-file"` + CIFixModel *string `yaml:"ci-fix-model"` + CIFixEffort *string `yaml:"ci-fix-reasoning-effort"` + CIFixPromptPath *string `yaml:"ci-fix-prompt-file"` + ReviewBase *string `yaml:"review-base"` + SessionLogDir *string `yaml:"session-log-dir"` + SessionLogRetention *string `yaml:"session-log-retention"` +} + type stageProfile struct { reviewModel, reviewEffort string fixModel, fixEffort string @@ -183,10 +209,10 @@ func Load(cwd, home string, overrides Overrides) (Config, error) { {name: "review-reasoning-effort", file: "review-reasoning-effort", env: "CODE_CONVERGE_REVIEW_REASONING_EFFORT", def: profile.reviewEffort, builtIn: fast.reviewEffort, defSource: profileSource, override: overrides.ReviewEffort}, {name: "fix-model", file: "fix-model", env: "CODE_CONVERGE_FIX_MODEL", def: profile.fixModel, builtIn: fast.fixModel, defSource: profileSource, override: overrides.FixModel}, {name: "fix-reasoning-effort", file: "fix-reasoning-effort", env: "CODE_CONVERGE_FIX_REASONING_EFFORT", def: profile.fixEffort, builtIn: fast.fixEffort, defSource: profileSource, override: overrides.FixEffort}, - {name: "fix-prompt", file: "fix-findings.md", env: "CODE_CONVERGE_FIX_PROMPT_FILE", def: "fix findings", builtIn: "fix findings", defSource: SourceDefault, override: overrides.FixPromptPath, promptFile: true}, + {name: "fix-prompt", file: "fix-prompt-file", env: "CODE_CONVERGE_FIX_PROMPT_FILE", def: "fix findings", builtIn: "fix findings", defSource: SourceDefault, override: overrides.FixPromptPath, promptFile: true}, {name: "ci-fix-model", file: "ci-fix-model", env: "CODE_CONVERGE_CI_FIX_MODEL", def: profile.ciFixModel, builtIn: fast.ciFixModel, defSource: profileSource, override: overrides.CIFixModel}, {name: "ci-fix-reasoning-effort", file: "ci-fix-reasoning-effort", env: "CODE_CONVERGE_CI_FIX_REASONING_EFFORT", def: profile.ciFixEffort, builtIn: fast.ciFixEffort, defSource: profileSource, override: overrides.CIFixEffort}, - {name: "ci-fix-prompt", file: "fix-ci.md", env: "CODE_CONVERGE_CI_FIX_PROMPT_FILE", def: "Исправь CI", builtIn: "Исправь CI", defSource: SourceDefault, override: overrides.CIFixPromptPath, promptFile: true}, + {name: "ci-fix-prompt", file: "ci-fix-prompt-file", env: "CODE_CONVERGE_CI_FIX_PROMPT_FILE", def: "Исправь CI", builtIn: "Исправь CI", defSource: SourceDefault, override: overrides.CIFixPromptPath, promptFile: true}, {name: "review-base", file: "review-base", env: "CODE_CONVERGE_REVIEW_BASE", def: "", builtIn: "", defSource: SourceDefault, override: overrides.ReviewBase}, {name: "session-log-dir", file: "session-log-dir", env: "CODE_CONVERGE_SESSION_LOG_DIR", def: filepath.Join(home, ".code-converge", "session-logs"), builtIn: filepath.Join(home, ".code-converge", "session-logs"), defSource: SourceDefault, override: overrides.SessionLogDir}, {name: "session-log-retention", file: "session-log-retention", env: "CODE_CONVERGE_SESSION_LOG_RETENTION", def: "24h", builtIn: "24h", defSource: SourceDefault, override: overrides.SessionLogRetention}, @@ -266,19 +292,6 @@ func rejectObsoleteFinalizeSettings(userDir, projectDir string) error { return fmt.Errorf("%s was removed; remove this obsolete Finalize-stage setting", name) } } - for _, directory := range []struct { - path string - source string - }{{userDir, "user"}, {projectDir, "project"}} { - for _, name := range []string{"finalize-model", "finalize-reasoning-effort", "finalize.md"} { - path := filepath.Join(directory.path, name) - if _, err := os.Stat(path); err == nil { - return fmt.Errorf("%s Finalize-stage setting %q was removed; delete it", directory.source, path) - } else if !os.IsNotExist(err) { - return fmt.Errorf("inspect obsolete Finalize-stage setting %q: %w", path, err) - } - } - } return nil } @@ -359,18 +372,22 @@ func resolve(item spec, cwd, userDir, projectDir string) (string, Setting, error source = SourceEnv } for _, candidate := range []struct{ dir, source string }{{userDir, SourceUser}, {projectDir, SourceProject}} { - path := filepath.Join(candidate.dir, item.file) - content, err := os.ReadFile(path) + config, err := readFileConfig(candidate.dir) if err != nil { - if os.IsNotExist(err) { - continue - } - return "", Setting{}, fmt.Errorf("read %s: %w", path, err) + return "", Setting{}, err + } + content, ok := config[item.file] + if !ok { + continue } if item.promptFile { - value, display = string(content), path + prompt, path, err := readConfiguredPrompt(candidate.dir, content) + if err != nil { + return "", Setting{}, fmt.Errorf("%s from %s config: %w", item.name, candidate.source, err) + } + value, display = prompt, path } else { - value, display = strings.TrimSpace(string(content)), strings.TrimSpace(string(content)) + value, display = strings.TrimSpace(content), strings.TrimSpace(content) } source = candidate.source } @@ -389,6 +406,74 @@ func resolve(item spec, cwd, userDir, projectDir string) (string, Setting, error return value, Setting{Name: item.name, Value: value, Source: source, Default: item.builtIn, DisplayValue: display, DisplayDefault: displayDefault(item)}, nil } +func readFileConfig(dir string) (fileConfig, error) { + path := filepath.Join(dir, "config.yaml") + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return fileConfig{}, nil + } + return nil, fmt.Errorf("read %s: %w", path, err) + } + var decoded yamlFileConfig + decoder := yaml.NewDecoder(strings.NewReader(string(content))) + decoder.KnownFields(true) + if err := decoder.Decode(&decoded); err == io.EOF { + return fileConfig{}, nil + } else if err != nil { + return nil, fmt.Errorf("invalid YAML configuration %s: %w", path, err) + } + var extra yaml.Node + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("invalid YAML configuration %s: only one YAML document is permitted", path) + } + return nil, fmt.Errorf("invalid YAML configuration %s: %w", path, err) + } + values := fileConfig{} + setString := func(key string, value *string) { + if value != nil { + values[key] = *value + } + } + setInt := func(key string, value *int) { + if value != nil { + values[key] = strconv.Itoa(*value) + } + } + setString("log-format", decoded.LogFormat) + setString("heartbeat", decoded.Heartbeat) + setString("color", decoded.Color) + setString("mode", decoded.Mode) + setInt("max-cycles", decoded.MaxCycles) + setInt("max-ci-recoveries", decoded.MaxCIRecoveries) + setString("ci-timeout", decoded.CITimeout) + setString("review-model", decoded.ReviewModel) + setString("review-reasoning-effort", decoded.ReviewEffort) + setString("fix-model", decoded.FixModel) + setString("fix-reasoning-effort", decoded.FixEffort) + setString("fix-prompt-file", decoded.FixPromptPath) + setString("ci-fix-model", decoded.CIFixModel) + setString("ci-fix-reasoning-effort", decoded.CIFixEffort) + setString("ci-fix-prompt-file", decoded.CIFixPromptPath) + setString("review-base", decoded.ReviewBase) + setString("session-log-dir", decoded.SessionLogDir) + setString("session-log-retention", decoded.SessionLogRetention) + return values, nil +} + +func readConfiguredPrompt(configDir, value string) (string, string, error) { + path := value + if !filepath.IsAbs(path) { + path = filepath.Join(configDir, path) + } + content, err := os.ReadFile(path) + if err != nil { + return "", "", fmt.Errorf("read prompt file %s: %w", path, err) + } + return string(content), path, nil +} + func displayDefault(item spec) string { return displayValue(item.builtIn, item.promptFile) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 29d10cb..0c5d5a8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,290 +4,186 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "testing" "time" ) -func TestMain(m *testing.M) { - clearGitRepositoryEnvironment() - os.Exit(m.Run()) +var codeConvergeEnv = []string{ + "CODE_CONVERGE_LOG_FORMAT", "CODE_CONVERGE_HEARTBEAT", "CODE_CONVERGE_COLOR", "CODE_CONVERGE_MODE", + "CODE_CONVERGE_MAX_CYCLES", "CODE_CONVERGE_MAX_CI_RECOVERIES", "CODE_CONVERGE_CI_TIMEOUT", "CODE_CONVERGE_REVIEW_MODEL", "CODE_CONVERGE_REVIEW_REASONING_EFFORT", + "CODE_CONVERGE_FIX_MODEL", "CODE_CONVERGE_FIX_REASONING_EFFORT", "CODE_CONVERGE_FIX_PROMPT_FILE", "CODE_CONVERGE_FINALIZE_MODEL", + "CODE_CONVERGE_FINALIZE_REASONING_EFFORT", "CODE_CONVERGE_FINALIZE_PROMPT_FILE", "CODE_CONVERGE_CI_FIX_MODEL", + "CODE_CONVERGE_CI_FIX_REASONING_EFFORT", "CODE_CONVERGE_CI_FIX_PROMPT_FILE", "CODE_CONVERGE_REVIEW_BASE", + "CODE_CONVERGE_SESSION_LOG_DIR", "CODE_CONVERGE_SESSION_LOG_RETENTION", } -func clearGitRepositoryEnvironment() { - for _, name := range []string{ - "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", - "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", - "GIT_NAMESPACE", "GIT_CEILING_DIRECTORIES", - "GIT_DISCOVERY_ACROSS_FILESYSTEM", "GIT_IMPLICIT_WORK_TREE", - } { +func TestMain(m *testing.M) { + for _, name := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"} { _ = os.Unsetenv(name) } + os.Exit(m.Run()) } -var codeConvergeEnv = []string{ - "CODE_CONVERGE_LOG_FORMAT", "CODE_CONVERGE_HEARTBEAT", "CODE_CONVERGE_COLOR", - "CODE_CONVERGE_MODE", - "CODE_CONVERGE_MAX_CYCLES", "CODE_CONVERGE_MAX_CI_RECOVERIES", "CODE_CONVERGE_CI_TIMEOUT", "CODE_CONVERGE_REVIEW_MODEL", "CODE_CONVERGE_REVIEW_REASONING_EFFORT", - "CODE_CONVERGE_FIX_MODEL", "CODE_CONVERGE_FIX_REASONING_EFFORT", "CODE_CONVERGE_FIX_PROMPT_FILE", "CODE_CONVERGE_CI_FIX_MODEL", - "CODE_CONVERGE_CI_FIX_REASONING_EFFORT", "CODE_CONVERGE_CI_FIX_PROMPT_FILE", - "CODE_CONVERGE_FINALIZE_MODEL", "CODE_CONVERGE_FINALIZE_REASONING_EFFORT", "CODE_CONVERGE_FINALIZE_PROMPT_FILE", - "CODE_CONVERGE_REVIEW_BASE", - "CODE_CONVERGE_SESSION_LOG_DIR", "CODE_CONVERGE_SESSION_LOG_RETENTION", -} - -func TestLoggingConfiguration(t *testing.T) { +func TestYAMLPrecedenceAndConfigReport(t *testing.T) { cleanEnv(t) root, home := repo(t) - t.Setenv("CODE_CONVERGE_LOG_FORMAT", "human") - write(t, filepath.Join(root, ".code-converge", "heartbeat"), "30s\n") - cfg, err := Load(root, home, Overrides{Color: OptionalString{Value: "never", Set: true}}) + t.Setenv("CODE_CONVERGE_MAX_CYCLES", "2") + writeConfig(t, home, "max-cycles: 3\nmode: best\n") + writeConfig(t, root, "max-cycles: 4\nmode: fast\n") + cfg, err := Load(root, home, Overrides{MaxCycles: OptionalString{Value: "5", Set: true}}) if err != nil { t.Fatal(err) } - if cfg.LogFormat != "human" || cfg.Heartbeat != 30*time.Second || cfg.Color != "never" { - t.Fatalf("logging config = %#v", cfg) + if cfg.MaxCycles != 5 || cfg.Mode != "fast" || source(cfg, "max-cycles") != SourceCLI || source(cfg, "mode") != SourceProject { + t.Fatalf("cfg = %#v", cfg) } - for _, want := range []string{"log-format: human (environment)", "heartbeat: 30s (project; built-in: 0)", "color: never (cli; built-in: auto)"} { - if !strings.Contains(Format(cfg), want) { - t.Errorf("missing %q in:\n%s", want, Format(cfg)) - } + if got := Format(cfg); !strings.Contains(got, "max-cycles: 5 (cli; built-in: 10)") || !strings.Contains(got, "mode: fast (project)") { + t.Fatalf("config output:\n%s", got) + } + cfg, err = Load(root, home, Overrides{}) + if err != nil || cfg.MaxCycles != 4 || source(cfg, "max-cycles") != SourceProject { + t.Fatalf("project precedence: %#v, %v", cfg, err) + } + if err := os.Remove(filepath.Join(root, ".code-converge", "config.yaml")); err != nil { + t.Fatal(err) + } + cfg, err = Load(root, home, Overrides{}) + if err != nil || cfg.MaxCycles != 3 || source(cfg, "max-cycles") != SourceUser { + t.Fatalf("user precedence: %#v, %v", cfg, err) + } + if err := os.Remove(filepath.Join(home, ".code-converge", "config.yaml")); err != nil { + t.Fatal(err) + } + cfg, err = Load(root, home, Overrides{}) + if err != nil || cfg.MaxCycles != 2 || source(cfg, "max-cycles") != SourceEnv { + t.Fatalf("environment precedence: %#v, %v", cfg, err) } } -func TestCITimeoutPrecedenceAndValidation(t *testing.T) { +func TestYAMLRepresentsAllSettings(t *testing.T) { cleanEnv(t) root, home := repo(t) - t.Setenv("CODE_CONVERGE_CI_TIMEOUT", "20m") - write(t, filepath.Join(home, ".code-converge", "ci-timeout"), "30m") - write(t, filepath.Join(root, ".code-converge", "ci-timeout"), "40m") - cfg, err := Load(root, home, Overrides{CITimeout: OptionalString{Value: "50m", Set: true}}) - if err != nil || cfg.CITimeout != 50*time.Minute || source(cfg, "ci-timeout") != SourceCLI { - t.Fatalf("ci timeout = %s (%s), %v", cfg.CITimeout, source(cfg, "ci-timeout"), err) + write(t, filepath.Join(root, ".code-converge", "prompts", "fix.md"), "fix prompt\n") + write(t, filepath.Join(root, ".code-converge", "prompts", "ci.md"), "ci prompt\n") + writeConfig(t, root, strings.Join([]string{ + "log-format: human", "heartbeat: 2s", "color: never", "mode: best", "max-cycles: 1", "max-ci-recoveries: 2", "ci-timeout: 30m", + "review-model: review", "review-reasoning-effort: low", "fix-model: fix", "fix-reasoning-effort: medium", "fix-prompt-file: prompts/fix.md", + "ci-fix-model: ci", "ci-fix-reasoning-effort: medium", "ci-fix-prompt-file: prompts/ci.md", + "review-base: main", "session-log-dir: " + filepath.Join(home, "logs"), "session-log-retention: 2h", + }, "\n")+"\n") + cfg, err := Load(root, home, Overrides{}) + if err != nil { + t.Fatal(err) } - if _, err := Load(root, home, Overrides{CITimeout: OptionalString{Value: "0s", Set: true}}); err == nil { - t.Fatal("accepted invalid ci timeout") + if cfg.Heartbeat != 2*time.Second || cfg.MaxCycles != 1 || cfg.MaxCIRecoveries != 2 || cfg.CITimeout != 30*time.Minute || cfg.ReviewModel != "review" || cfg.FixPrompt != "fix prompt\n" || cfg.CIFixPrompt != "ci prompt\n" || cfg.ReviewBase != "main" || cfg.SessionLogRetention != 2*time.Hour { + t.Fatalf("cfg = %#v", cfg) } -} - -func TestCITimeoutSourcePrecedence(t *testing.T) { - for _, test := range []struct { - name string - env string - user string - project string - override OptionalString - want time.Duration - wantSource string - }{ - {"built-in default", "", "", "", OptionalString{}, 60 * time.Minute, SourceDefault}, - {"environment", "20m", "", "", OptionalString{}, 20 * time.Minute, SourceEnv}, - {"user", "20m", "30m", "", OptionalString{}, 30 * time.Minute, SourceUser}, - {"project", "20m", "30m", "40m", OptionalString{}, 40 * time.Minute, SourceProject}, - {"CLI", "20m", "30m", "40m", OptionalString{Value: "50m", Set: true}, 50 * time.Minute, SourceCLI}, + for _, name := range []string{ + "log-format", "heartbeat", "color", "mode", "max-cycles", "max-ci-recoveries", "ci-timeout", + "review-model", "review-reasoning-effort", "fix-model", "fix-reasoning-effort", "fix-prompt", + "ci-fix-model", "ci-fix-reasoning-effort", "ci-fix-prompt", + "review-base", "session-log-dir", "session-log-retention", } { - t.Run(test.name, func(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - if test.env != "" { - t.Setenv("CODE_CONVERGE_CI_TIMEOUT", test.env) - } - if test.user != "" { - write(t, filepath.Join(home, ".code-converge", "ci-timeout"), test.user) - } - if test.project != "" { - write(t, filepath.Join(root, ".code-converge", "ci-timeout"), test.project) - } - cfg, err := Load(root, home, Overrides{CITimeout: test.override}) - if err != nil || cfg.CITimeout != test.want || source(cfg, "ci-timeout") != test.wantSource { - t.Fatalf("ci-timeout=%s (%s), err=%v; want %s (%s)", cfg.CITimeout, source(cfg, "ci-timeout"), err, test.want, test.wantSource) - } - }) + if source(cfg, name) != SourceProject { + t.Errorf("%s source = %q", name, source(cfg, name)) + } } } -func TestObsoleteFinalizeSettingsFailExplicitly(t *testing.T) { - for _, test := range []struct { - name string - set func(t *testing.T, root, home string) - }{ - {"environment", func(t *testing.T, _, _ string) { t.Setenv("CODE_CONVERGE_FINALIZE_MODEL", "gpt-legacy") }}, - {"user file", func(t *testing.T, _, home string) { - write(t, filepath.Join(home, ".code-converge", "finalize-reasoning-effort"), "medium") - }}, - {"project prompt", func(t *testing.T, root, _ string) { - write(t, filepath.Join(root, ".code-converge", "finalize.md"), "publish") - }}, +func TestYAMLValidation(t *testing.T) { + for _, test := range []struct{ name, contents, want string }{ + {"malformed", "mode: [fast\n", "did not find expected"}, + {"unknown key", "typo: true\n", "field typo not found"}, + {"nested value", "mode:\n value: fast\n", "cannot unmarshal"}, + {"duplicate", "mode: fast\nmode: best\n", "mapping key \"mode\" already defined"}, + {"invalid mode", "mode: invalid\n", "mode must be one of"}, + {"invalid integer", "max-cycles: -1\n", "max-cycles must be a non-negative integer"}, + {"invalid duration", "session-log-retention: 0\n", "session-log-retention must be a duration of at least 1s"}, } { t.Run(test.name, func(t *testing.T) { cleanEnv(t) root, home := repo(t) - test.set(t, root, home) - if _, err := Load(root, home, Overrides{}); err == nil || !strings.Contains(err.Error(), "Finalize-stage setting") { - t.Fatalf("Load obsolete setting error = %v", err) + writeConfig(t, root, test.contents) + _, err := Load(root, home, Overrides{}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) } }) } } -func TestLoggingConfigurationPrecedence(t *testing.T) { +func TestYAMLPromptPathFailureIsActionable(t *testing.T) { cleanEnv(t) root, home := repo(t) - t.Setenv("CODE_CONVERGE_LOG_FORMAT", "human") - t.Setenv("CODE_CONVERGE_HEARTBEAT", "1s") - t.Setenv("CODE_CONVERGE_COLOR", "never") - for _, item := range []struct{ dir, name, value string }{ - {filepath.Join(home, ".code-converge"), "log-format", "human"}, - {filepath.Join(home, ".code-converge"), "heartbeat", "2s"}, - {filepath.Join(home, ".code-converge"), "color", "auto"}, - {filepath.Join(root, ".code-converge"), "log-format", "human"}, - {filepath.Join(root, ".code-converge"), "heartbeat", "3s"}, - {filepath.Join(root, ".code-converge"), "color", "never"}, - } { - write(t, filepath.Join(item.dir, item.name), item.value) - } - assert := func(wantSource, wantHeartbeat, wantColor string, overrides Overrides) { - t.Helper() - cfg, err := Load(root, home, overrides) - if err != nil { - t.Fatal(err) - } - if source(cfg, "log-format") != wantSource || source(cfg, "heartbeat") != wantSource || source(cfg, "color") != wantSource || cfg.Heartbeat.String() != wantHeartbeat || cfg.Color != wantColor { - t.Fatalf("source/value = %s/%s/%s %s %s", source(cfg, "log-format"), source(cfg, "heartbeat"), source(cfg, "color"), cfg.Heartbeat, cfg.Color) - } - } - assert(SourceCLI, "4s", "auto", Overrides{ - LogFormat: OptionalString{"human", true}, Heartbeat: OptionalString{"4s", true}, Color: OptionalString{"auto", true}, - }) - assert(SourceProject, "3s", "never", Overrides{}) - for _, name := range []string{"log-format", "heartbeat", "color"} { - if err := os.Remove(filepath.Join(root, ".code-converge", name)); err != nil { - t.Fatal(err) - } - } - assert(SourceUser, "2s", "auto", Overrides{}) - for _, name := range []string{"log-format", "heartbeat", "color"} { - if err := os.Remove(filepath.Join(home, ".code-converge", name)); err != nil { - t.Fatal(err) - } - } - assert(SourceEnv, "1s", "never", Overrides{}) -} - -func TestInvalidLoggingConfiguration(t *testing.T) { - tests := []Overrides{ - {LogFormat: OptionalString{"json", true}}, - {Color: OptionalString{"always", true}}, - {LogFormat: OptionalString{"human", true}, Heartbeat: OptionalString{"500ms", true}}, - {LogFormat: OptionalString{"human", true}, Heartbeat: OptionalString{"-1s", true}}, - {LogFormat: OptionalString{"kv", true}, Heartbeat: OptionalString{"1s", true}}, - } - for _, overrides := range tests { - cleanEnv(t) - root, home := repo(t) - if _, err := Load(root, home, overrides); err == nil { - t.Errorf("accepted invalid overrides: %#v", overrides) - } + writeConfig(t, root, "fix-prompt-file: prompts/missing.md\n") + _, err := Load(root, home, Overrides{}) + if err == nil || !strings.Contains(err.Error(), "fix-prompt from project config") || !strings.Contains(err.Error(), "prompts/missing.md") { + t.Fatalf("error = %v", err) } } -func TestDefaultLogFormatIsHuman(t *testing.T) { +func TestLegacyFilesAreIgnored(t *testing.T) { cleanEnv(t) root, home := repo(t) + write(t, filepath.Join(home, ".code-converge", "mode"), "best\n") + write(t, filepath.Join(root, ".code-converge", "max-cycles"), "1\n") + write(t, filepath.Join(root, ".code-converge", "fix-findings.md"), "legacy prompt\n") cfg, err := Load(root, home, Overrides{}) if err != nil { t.Fatal(err) } - if cfg.LogFormat != "human" || source(cfg, "log-format") != SourceDefault { - t.Fatalf("log format = %q from %q, want human from built-in default", cfg.LogFormat, source(cfg, "log-format")) - } - if !strings.Contains(Format(cfg), "log-format: human (built-in default)") { - t.Fatalf("config output:\n%s", Format(cfg)) + if cfg.Mode != "fast" || cfg.MaxCycles != 10 || cfg.FixPrompt != "fix findings" || source(cfg, "mode") != SourceDefault { + t.Fatalf("legacy file was read: %#v", cfg) } } -func TestSessionLogConfiguration(t *testing.T) { +func TestCLIAndEnvironmentStillWin(t *testing.T) { cleanEnv(t) root, home := repo(t) - projectDir := filepath.Join(root, "logs") - write(t, filepath.Join(home, ".code-converge", "session-log-dir"), filepath.Join(home, "user-logs")) - write(t, filepath.Join(root, ".code-converge", "session-log-dir"), projectDir) - write(t, filepath.Join(root, ".code-converge", "session-log-retention"), "2h") - cfg, err := Load(root, home, Overrides{SessionLogDir: OptionalString{Value: "~/cli-logs", Set: true}, SessionLogRetention: OptionalString{Value: "3h", Set: true}, NoSessionLog: true}) - if err != nil { - t.Fatal(err) - } - if cfg.SessionLogDir != filepath.Join(home, "cli-logs") || cfg.SessionLogRetention != 3*time.Hour || !cfg.NoSessionLog || source(cfg, "session-log-dir") != SourceCLI || source(cfg, "session-log-retention") != SourceCLI { - t.Fatalf("cfg=%#v", cfg) + t.Setenv("CODE_CONVERGE_COLOR", "never") + writeConfig(t, home, "color: auto\n") + writeConfig(t, root, "color: never\n") + cfg, err := Load(root, home, Overrides{Color: OptionalString{Value: "auto", Set: true}}) + if err != nil || cfg.Color != "auto" || source(cfg, "color") != SourceCLI { + t.Fatalf("cfg=%#v err=%v", cfg, err) } - for _, overrides := range []Overrides{{SessionLogDir: OptionalString{Value: "relative", Set: true}}, {SessionLogRetention: OptionalString{Value: "0", Set: true}}, {SessionLogRetention: OptionalString{Value: "-1s", Set: true}}} { - if _, err := Load(root, home, overrides); err == nil { - t.Fatalf("accepted invalid session config: %#v", overrides) - } +} + +func TestResolveLogFormatReadsYAML(t *testing.T) { + cleanEnv(t) + root, home := repo(t) + writeConfig(t, root, "log-format: kv\n") + format, err := ResolveLogFormat(root, home, OptionalString{}) + if err != nil || format != "kv" { + t.Fatalf("format=%q err=%v", format, err) } } -func TestSessionLogConfigurationPrecedence(t *testing.T) { - for _, setting := range []struct { - name, env string - value func(string, string) string - get func(Config) string - }{ - {"session-log-dir", "CODE_CONVERGE_SESSION_LOG_DIR", func(_, home string) string { return filepath.Join(home, "environment") }, func(cfg Config) string { return cfg.SessionLogDir }}, - {"session-log-retention", "CODE_CONVERGE_SESSION_LOG_RETENTION", func(root, _ string) string { return "1h" }, func(cfg Config) string { - if cfg.SessionLogRetention == 3*time.Hour { - return "3h" - } - return cfg.SessionLogRetention.String() - }}, - } { - t.Run(setting.name, func(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - environment := setting.value(root, home) - user := setting.value(filepath.Join(root, "user"), home) - project := setting.value(filepath.Join(root, "project"), home) - cli := setting.value(filepath.Join(root, "cli"), home) - if setting.name == "session-log-dir" { - user, project, cli = filepath.Join(home, "user"), filepath.Join(home, "project"), filepath.Join(home, "cli") - } - t.Setenv(setting.env, environment) - write(t, filepath.Join(home, ".code-converge", setting.name), user) - write(t, filepath.Join(root, ".code-converge", setting.name), project) - overrides := Overrides{} - if setting.name == "session-log-dir" { - overrides.SessionLogDir = OptionalString{Value: cli, Set: true} - } else { - overrides.SessionLogRetention = OptionalString{Value: "3h", Set: true} - cli = "3h" - } - cfg, err := Load(root, home, overrides) - if err != nil || setting.get(cfg) != cli || source(cfg, setting.name) != SourceCLI { - t.Fatalf("cfg=%#v err=%v", cfg, err) - } - }) +func TestDefaultsAndProfileResolution(t *testing.T) { + cleanEnv(t) + root, home := repo(t) + writeConfig(t, root, "mode: best\n") + cfg, err := Load(root, home, Overrides{}) + if err != nil || cfg.ReviewModel != "gpt-5.6-sol" || cfg.FixModel != "gpt-5.6-terra" || source(cfg, "review-model") != "best profile" { + t.Fatalf("cfg=%#v err=%v", cfg, err) } } func cleanEnv(t *testing.T) { t.Helper() for _, name := range codeConvergeEnv { - t.Setenv(name, "") _ = os.Unsetenv(name) } } - func repo(t *testing.T) (string, string) { t.Helper() root := t.TempDir() - if output, err := exec.Command("git", "init", "-q", root).CombinedOutput(); err != nil { - t.Fatalf("git init: %v: %s", err, output) + if out, err := exec.Command("git", "init", "-q", root).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, out) } - home := t.TempDir() - return root, home + return root, t.TempDir() } - func write(t *testing.T, path, value string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -297,243 +193,10 @@ func write(t *testing.T, path, value string) { t.Fatal(err) } } - -func TestLoadPrecedence(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - t.Setenv("CODE_CONVERGE_MAX_CYCLES", "2") - write(t, filepath.Join(home, ".code-converge", "max-cycles"), "3\n") - write(t, filepath.Join(root, ".code-converge", "max-cycles"), "4\n") - cfg, err := Load(root, home, Overrides{MaxCycles: OptionalString{Value: "5", Set: true}}) - if err != nil { - t.Fatal(err) - } - if cfg.MaxCycles != 5 || source(cfg, "max-cycles") != SourceCLI { - t.Fatalf("max cycles = %d (%s)", cfg.MaxCycles, source(cfg, "max-cycles")) - } - cfg, err = Load(root, home, Overrides{}) - if err != nil || cfg.MaxCycles != 4 || source(cfg, "max-cycles") != SourceProject { - t.Fatalf("project precedence = %d (%s), %v", cfg.MaxCycles, source(cfg, "max-cycles"), err) - } - if !strings.Contains(Format(cfg), "max-cycles: 4 (project; built-in: 10)") { - t.Fatalf("formatted config:\n%s", Format(cfg)) - } -} - -func TestReviewBasePrecedence(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - t.Setenv("CODE_CONVERGE_REVIEW_BASE", "env-base") - write(t, filepath.Join(home, ".code-converge", "review-base"), "user-base") - write(t, filepath.Join(root, ".code-converge", "review-base"), "project-base") - cfg, err := Load(root, home, Overrides{ReviewBase: OptionalString{Value: "cli-base", Set: true}}) - if err != nil || cfg.ReviewBase != "cli-base" || source(cfg, "review-base") != SourceCLI { - t.Fatalf("review base = %q (%s), %v", cfg.ReviewBase, source(cfg, "review-base"), err) - } - cfg, err = Load(root, home, Overrides{}) - if err != nil || cfg.ReviewBase != "project-base" || source(cfg, "review-base") != SourceProject { - t.Fatalf("review base = %q (%s), %v", cfg.ReviewBase, source(cfg, "review-base"), err) - } - if err := os.Remove(filepath.Join(root, ".code-converge", "review-base")); err != nil { - t.Fatal(err) - } - if err := os.Remove(filepath.Join(home, ".code-converge", "review-base")); err != nil { - t.Fatal(err) - } - t.Setenv("CODE_CONVERGE_REVIEW_BASE", "") - _ = os.Unsetenv("CODE_CONVERGE_REVIEW_BASE") - cfg, err = Load(root, home, Overrides{}) - if err != nil || !strings.Contains(Format(cfg), "review-base: discover (built-in default)") { - t.Fatalf("default review base config:\n%s\nerr=%v", Format(cfg), err) - } -} - -func TestProfileResolution(t *testing.T) { - tests := []struct { - name string - overrides Overrides - wantMode string - want []string - }{ - { - name: "default fast", wantMode: "fast", - want: []string{"gpt-5.6-terra", "medium", "gpt-5.6-luna", "medium", "gpt-5.6-luna", "medium"}, - }, - { - name: "explicit best", overrides: Overrides{Mode: OptionalString{Value: "best", Set: true}}, wantMode: "best", - want: []string{"gpt-5.6-sol", "high", "gpt-5.6-terra", "high", "gpt-5.6-terra", "high"}, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - cfg, err := Load(root, home, test.overrides) - if err != nil { - t.Fatal(err) - } - got := []string{cfg.ReviewModel, cfg.ReviewEffort, cfg.FixModel, cfg.FixEffort, cfg.CIFixModel, cfg.CIFixEffort} - if cfg.Mode != test.wantMode || strings.Join(got, "|") != strings.Join(test.want, "|") { - t.Fatalf("mode/profile = %s %q, want %s %q", cfg.Mode, got, test.wantMode, test.want) - } - for _, name := range []string{"review-model", "review-reasoning-effort", "fix-model", "fix-reasoning-effort", "ci-fix-model", "ci-fix-reasoning-effort"} { - if gotSource := source(cfg, name); gotSource != test.wantMode+" profile" { - t.Errorf("%s source = %q", name, gotSource) - } - } - }) - } -} - -func TestModePrecedence(t *testing.T) { - tests := []struct { - name string - want string - wantMode string - set func(*testing.T, string, string, *Overrides) - }{ - {"environment", SourceEnv, "best", func(t *testing.T, _, _ string, _ *Overrides) { t.Setenv("CODE_CONVERGE_MODE", "best") }}, - {"user", SourceUser, "fast", func(t *testing.T, _, home string, _ *Overrides) { - t.Setenv("CODE_CONVERGE_MODE", "best") - write(t, filepath.Join(home, ".code-converge", "mode"), "fast") - }}, - {"project", SourceProject, "fast", func(t *testing.T, root, home string, _ *Overrides) { - t.Setenv("CODE_CONVERGE_MODE", "fast") - write(t, filepath.Join(home, ".code-converge", "mode"), "best") - write(t, filepath.Join(root, ".code-converge", "mode"), "fast") - }}, - {"cli", SourceCLI, "fast", func(t *testing.T, root, home string, overrides *Overrides) { - t.Setenv("CODE_CONVERGE_MODE", "best") - write(t, filepath.Join(home, ".code-converge", "mode"), "fast") - write(t, filepath.Join(root, ".code-converge", "mode"), "best") - overrides.Mode = OptionalString{Value: "fast", Set: true} - }}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - overrides := Overrides{} - test.set(t, root, home, &overrides) - cfg, err := Load(root, home, overrides) - if err != nil || cfg.Mode != test.wantMode || source(cfg, "mode") != test.want { - t.Fatalf("mode = %q (%s), %v", cfg.Mode, source(cfg, "mode"), err) - } - }) - } -} - -func TestEveryStageOverrideSourceBeatsProfile(t *testing.T) { - type field struct { - name string - env string - set func(*Overrides, string) - get func(Config) string - } - fields := []field{ - {"review-model", "CODE_CONVERGE_REVIEW_MODEL", func(o *Overrides, v string) { o.ReviewModel = OptionalString{v, true} }, func(c Config) string { return c.ReviewModel }}, - {"review-reasoning-effort", "CODE_CONVERGE_REVIEW_REASONING_EFFORT", func(o *Overrides, v string) { o.ReviewEffort = OptionalString{v, true} }, func(c Config) string { return c.ReviewEffort }}, - {"fix-model", "CODE_CONVERGE_FIX_MODEL", func(o *Overrides, v string) { o.FixModel = OptionalString{v, true} }, func(c Config) string { return c.FixModel }}, - {"fix-reasoning-effort", "CODE_CONVERGE_FIX_REASONING_EFFORT", func(o *Overrides, v string) { o.FixEffort = OptionalString{v, true} }, func(c Config) string { return c.FixEffort }}, - {"ci-fix-model", "CODE_CONVERGE_CI_FIX_MODEL", func(o *Overrides, v string) { o.CIFixModel = OptionalString{v, true} }, func(c Config) string { return c.CIFixModel }}, - {"ci-fix-reasoning-effort", "CODE_CONVERGE_CI_FIX_REASONING_EFFORT", func(o *Overrides, v string) { o.CIFixEffort = OptionalString{v, true} }, func(c Config) string { return c.CIFixEffort }}, - } - sources := []struct { - name string - want string - set func(*testing.T, string, string, field, *Overrides) - }{ - {"environment", SourceEnv, func(t *testing.T, _, _ string, f field, _ *Overrides) { t.Setenv(f.env, "environment-value") }}, - {"user", SourceUser, func(t *testing.T, _, home string, f field, _ *Overrides) { - t.Setenv(f.env, "environment-value") - write(t, filepath.Join(home, ".code-converge", f.name), "user-value") - }}, - {"project", SourceProject, func(t *testing.T, root, home string, f field, _ *Overrides) { - t.Setenv(f.env, "environment-value") - write(t, filepath.Join(home, ".code-converge", f.name), "user-value") - write(t, filepath.Join(root, ".code-converge", f.name), "project-value") - }}, - {"cli", SourceCLI, func(t *testing.T, root, home string, f field, o *Overrides) { - t.Setenv(f.env, "environment-value") - write(t, filepath.Join(home, ".code-converge", f.name), "user-value") - write(t, filepath.Join(root, ".code-converge", f.name), "project-value") - f.set(o, "cli-value") - }}, - } - for _, f := range fields { - for _, candidate := range sources { - t.Run(f.name+"/"+candidate.name, func(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - overrides := Overrides{Mode: OptionalString{Value: "best", Set: true}} - candidate.set(t, root, home, f, &overrides) - cfg, err := Load(root, home, overrides) - if err != nil { - t.Fatal(err) - } - if f.get(cfg) != candidate.name+"-value" || source(cfg, f.name) != candidate.want { - t.Fatalf("%s = %q (%s)", f.name, f.get(cfg), source(cfg, f.name)) - } - }) - } - } -} - -func TestInvalidModes(t *testing.T) { - for _, value := range []string{"", " ", "FAST", "unknown"} { - t.Run(strconv.Quote(value), func(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - _, err := Load(root, home, Overrides{Mode: OptionalString{Value: value, Set: true}}) - if err == nil || !strings.Contains(err.Error(), "mode must be one of") { - t.Fatalf("error = %v", err) - } - }) - } -} - -func TestPromptResolutionAndMissingExplicitPath(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - write(t, filepath.Join(home, ".code-converge", "fix-findings.md"), "user prompt\n") - cfg, err := Load(root, home, Overrides{}) - if err != nil || cfg.FixPrompt != "user prompt\n" || source(cfg, "fix-prompt") != SourceUser { - t.Fatalf("prompt = %q (%s), %v", cfg.FixPrompt, source(cfg, "fix-prompt"), err) - } - _, err = Load(root, home, Overrides{FixPromptPath: OptionalString{Value: "missing.md", Set: true}}) - if err == nil || !strings.Contains(err.Error(), "missing.md") { - t.Fatalf("missing path error = %v", err) - } -} - -func TestValidationAndGitRoot(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - child := filepath.Join(root, "a", "b") - if err := os.MkdirAll(child, 0o755); err != nil { - t.Fatal(err) - } - cfg, err := Load(child, home, Overrides{MaxCycles: OptionalString{Value: "-1", Set: true}}) - if err == nil || cfg.Root != "" { - t.Fatalf("negative value accepted: %#v, %v", cfg, err) - } - cfg, err = Load(child, home, Overrides{}) - physicalRoot, physicalErr := filepath.EvalSymlinks(root) - if err != nil || physicalErr != nil || cfg.Root != physicalRoot { - t.Fatalf("root = %s, want %s: %v / %v", cfg.Root, physicalRoot, err, physicalErr) - } - if _, err := FindGitRoot(t.TempDir()); err == nil { - t.Fatal("non-git directory accepted") - } - fake := t.TempDir() - if err := os.Mkdir(filepath.Join(fake, ".git"), 0o755); err != nil { - t.Fatal(err) - } - if _, err := FindGitRoot(fake); err == nil { - t.Fatal("empty .git marker accepted") - } +func writeConfig(t *testing.T, directory, value string) { + t.Helper() + write(t, filepath.Join(directory, ".code-converge", "config.yaml"), value) } - func source(cfg Config, name string) string { for _, setting := range cfg.Settings { if setting.Name == name { @@ -542,81 +205,3 @@ func source(cfg Config, name string) string { } return "" } - -func TestLoadEmptyStageSettingValidation(t *testing.T) { - for _, name := range []string{"review-model", "review-reasoning-effort", "fix-model", "fix-reasoning-effort", "ci-fix-model", "ci-fix-reasoning-effort"} { - t.Run(name, func(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - write(t, filepath.Join(root, ".code-converge", name), " ") - _, err := Load(root, home, Overrides{}) - if err == nil || !strings.Contains(err.Error(), name+" must not be empty") { - t.Fatalf("error = %v", err) - } - }) - } -} - -func TestResolveFileReadError(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - path := filepath.Join(home, ".code-converge", "max-cycles") - if err := os.MkdirAll(path, 0o700); err != nil { - t.Fatal(err) - } - _, err := Load(root, home, Overrides{}) - if err == nil { - t.Fatal("expected read error") - } -} - -func TestReadExplicitPromptAbsolutePath(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - tmp := t.TempDir() - path := filepath.Join(tmp, "prompt.md") - if err := os.WriteFile(path, []byte("absolute prompt\n"), 0o600); err != nil { - t.Fatal(err) - } - cfg, err := Load(root, home, Overrides{FixPromptPath: OptionalString{Value: path, Set: true}}) - if err != nil || cfg.FixPrompt != "absolute prompt\n" { - t.Fatalf("prompt = %q, %v", cfg.FixPrompt, err) - } -} - -func TestFormatDefaultSource(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - cfg, err := Load(root, home, Overrides{}) - if err != nil { - t.Fatal(err) - } - formatted := Format(cfg) - for _, line := range strings.Split(formatted, "\n") { - if line != "" && strings.Contains(line, "built-in:") { - t.Fatalf("default source should not show built-in: %q", line) - } - } -} - -func TestFormatProfileAndEqualExplicitSources(t *testing.T) { - cleanEnv(t) - root, home := repo(t) - cfg, err := Load(root, home, Overrides{ - Mode: OptionalString{Value: "best", Set: true}, - ReviewModel: OptionalString{Value: "gpt-5.6-terra", Set: true}, - }) - if err != nil { - t.Fatal(err) - } - formatted := Format(cfg) - for _, want := range []string{ - "mode: best (cli; built-in: fast)", - "review-model: gpt-5.6-terra (cli)", - "fix-model: gpt-5.6-terra (best profile; built-in: gpt-5.6-luna)", - } { - if !strings.Contains(formatted, want) { - t.Errorf("missing %q in:\n%s", want, formatted) - } - } -} diff --git a/memory-bank/features/FT-038/README.md b/memory-bank/features/FT-038/README.md new file mode 100644 index 0000000..15ba4e4 --- /dev/null +++ b/memory-bank/features/FT-038/README.md @@ -0,0 +1,17 @@ +--- +title: "FT-038: YAML Configuration" +doc_kind: feature +doc_function: index +purpose: "Navigation for the configuration-format delivery from issue #38." +derived_from: + - ../../dna/governance.md + - brief.md +status: active +audience: humans_and_agents +--- + +# FT-038: YAML Configuration + +- [`brief.md`](brief.md) — canonical scope, validation profile and acceptance contract. +- [`design.md`](design.md) — selected strict YAML contract and breaking-release decision. +- [`implementation-plan.md`](implementation-plan.md) — implementation and verification evidence. diff --git a/memory-bank/features/FT-038/brief.md b/memory-bank/features/FT-038/brief.md new file mode 100644 index 0000000..5f2e202 --- /dev/null +++ b/memory-bank/features/FT-038/brief.md @@ -0,0 +1,56 @@ +--- +title: "FT-038: YAML Configuration" +doc_kind: feature +doc_function: canonical +purpose: "Canonical problem, scope and verification contract for issue #38." +derived_from: + - ../../flows/feature.md + - ../../engineering/validation-profiles.md + - ../../../README.md +status: active +delivery_status: active +audience: humans_and_agents +must_not_define: + - implementation_sequence + - solution_space +--- + +# FT-038: YAML Configuration + +## Scope + +- `REQ-01` Project and user configuration resolve only from their respective `config.yaml` files, with CLI > project > user > environment > defaults precedence. +- `REQ-02` The strict typed YAML schema covers every existing file-configurable setting, rejects malformed, unknown, duplicate and invalid values with useful diagnostics, and never reads legacy files. +- `REQ-03` Prompt YAML values are file references resolved from their configuration directory; CLI and environment prompt paths keep their existing behavior. +- `REQ-04` `code-converge config`, README and the operational configuration contract accurately report and document YAML sources. +- `REQ-05` The clean-break configuration contract is released only in the next SemVer major version. + +## Non-Scope + +- `NS-01` Migrating, warning about, or otherwise supporting legacy per-setting files. +- `NS-02` Changing CLI flags, environment variable names, model profiles or their precedence. + +## Design Requirement Decision + +`Design required: yes` — this changes a public configuration file format and release compatibility contract. + +## Validation Profile Decision + +`standard` — the public configuration contract and parser behavior change; no high-risk persistence, security, integration or deployment trigger applies. + +## Verify + +- `SC-01` A complete project `config.yaml` supplies all settings, including prompt references, and `config` reports `project` sources. +- `SC-02` Conflicting CLI, project YAML, user YAML and environment values resolve in the documented order. +- `SC-03` Malformed YAML, an unknown key, a duplicate key, a nested mapping and an invalid typed value fail with actionable errors. +- `SC-04` Legacy per-setting values and prompt files have no effect. + +| Check ID | Covers | Command | Evidence ID | +| --- | --- | --- | --- | +| `CHK-01` | `SC-01`–`SC-04` | `go test ./internal/config` | `EVID-01` | +| `CHK-02` | all requirements | `go test ./...`; `go vet ./...`; `git diff --check` | `EVID-02` | +| `CHK-03` | `REQ-04`, `REQ-05` | `make docs-lint` | `EVID-03` | + +- `EVID-01` Focused deterministic configuration tests. +- `EVID-02` Full Go verification and clean diff check. +- `EVID-03` Documentation lint and release-note review. diff --git a/memory-bank/features/FT-038/design.md b/memory-bank/features/FT-038/design.md new file mode 100644 index 0000000..df06727 --- /dev/null +++ b/memory-bank/features/FT-038/design.md @@ -0,0 +1,36 @@ +--- +title: "FT-038: YAML Configuration Design" +doc_kind: feature +doc_function: canonical +purpose: "Selected YAML configuration contract and compatibility decision for FT-038." +derived_from: + - brief.md + - ../../../README.md +status: active +audience: humans_and_agents +must_not_define: + - problem_scope + - execution_sequence +--- + +# FT-038: YAML Configuration Design + +- `SOL-01` Resolve only `/config.yaml` for file configuration. The resolver retains source metadata and existing precedence. +- `SOL-02` Decode a strict flat mapping whose key inventory is the public settings table. Scalar values are validated by their existing typed setting validators; unknown, duplicate, malformed and nested YAML fails with file-and-line diagnostics. +- `SOL-03` Represent prompts with `fix-prompt-file`, `finalize-prompt-file` and `ci-fix-prompt-file`; each value is a path resolved relative to its YAML directory unless absolute. +- `SD-01` No legacy read, migration or fallback is allowed. +- `SD-02` This breaking file-format change requires the next SemVer major release; it is recorded under Unreleased until that release is prepared. + +## C4 Applicability Decision + +`C4-00: not required` — the change remains within the `internal/config` resolver and adds no runtime, storage or integration boundary. + +## Architecture Coverage + +| Aspect | Decision | +| --- | --- | +| Components | `internal/config` owns decoding, source attribution and validation. | +| Connectors | N/A; local filesystem reads already exist. | +| Configuration | Project and user directories bind to one named document each. | +| Behavioral semantics | Precedence and CLI/environment contracts remain invariant; legacy files are excluded. | +| Evolution | Strict keys catch configuration drift; major release avoids silent compatibility claims. | diff --git a/memory-bank/features/FT-038/implementation-plan.md b/memory-bank/features/FT-038/implementation-plan.md new file mode 100644 index 0000000..d54edf9 --- /dev/null +++ b/memory-bank/features/FT-038/implementation-plan.md @@ -0,0 +1,28 @@ +--- +title: "FT-038: YAML Configuration Implementation Plan" +doc_kind: feature +doc_function: derived +purpose: "Execution and verification mapping for FT-038." +derived_from: + - brief.md + - design.md + - ../../engineering/testing-policy.md +status: active +audience: humans_and_agents +--- + +# FT-038: YAML Configuration Implementation Plan + +| Step | Implements | Evidence | +| --- | --- | --- | +| `STEP-01` | `SOL-01`–`SOL-03`, `SD-01` | Strict resolver and focused precedence/validation/no-legacy tests (`EVID-01`). | +| `STEP-02` | `REQ-04`, `SD-02` | README, operations contract, changelog and feature package (`EVID-03`). | +| `STEP-03` | all requirements | Full Go tests, vet, docs lint and diff check (`EVID-02`, `EVID-03`). | + +Rollback is a single commit revert; no configuration migration or persistent state exists. + +## Execution Evidence + +- `EVID-01` passed on 2026-07-31: `GOCACHE= go test ./internal/config`. +- `EVID-02` is partially blocked in this sandbox: `go test ./...` compiles and runs the affected configuration package successfully, but unrelated packages abort when the macOS loader reports `missing LC_UUID load command` for generated test binaries. `git diff --check` passes. +- `EVID-03` passed on 2026-07-31: `make docs-lint`. diff --git a/memory-bank/features/README.md b/memory-bank/features/README.md index f01faf8..464b8fa 100644 --- a/memory-bank/features/README.md +++ b/memory-bank/features/README.md @@ -48,4 +48,5 @@ audience: humans_and_agents - [`FT-024/README.md`](FT-024/README.md) — completed local checkpoints for successful findings fixes, with publication deferred to clean-review finalization for issue #24. - [`FT-028/README.md`](FT-028/README.md) — active remediation of stale interactive liveness frames for issue #28 through footprint-aware clearing and deterministic reflow coverage. - [`FT-036/README.md`](FT-036/README.md) — planned discoverable root and subcommand CLI help for issue #36. +- [`FT-038/README.md`](FT-038/README.md) — strict YAML configuration delivery for issue #38. - [`FT-039/README.md`](FT-039/README.md) — active deterministic repository publication and CI orchestration for issue #39. diff --git a/memory-bank/ops/config.md b/memory-bank/ops/config.md index 0c1d54d..179b4f2 100644 --- a/memory-bank/ops/config.md +++ b/memory-bank/ops/config.md @@ -18,4 +18,6 @@ The root [`README.md`](../../README.md) solely owns configuration source precede `code-converge config` prints each effective value and its source. If the effective value differs from its built-in default, it prints that default too. +Project and user file configuration each use exactly one strict, flat YAML document: `/.code-converge/config.yaml` and `~/.code-converge/config.yaml`. The root README owns its complete schema, precedence and examples. Per-setting files are not an operational compatibility path. + `codex` authentication and credentials for any configured Git remote or GitHub provider are environment prerequisites, not `code-converge` configuration values. GitHub credentials are required for Code Converge's deterministic pull-request and CI operations. The application must not log secrets or token values.