From 94be902ce1d832be9958c7f0844a6d4c9705b558 Mon Sep 17 00:00:00 2001 From: bellman Date: Wed, 3 Jun 2026 23:47:27 +0900 Subject: [PATCH] fix: attribute config precedence in JSON --- ROADMAP.md | 2 +- USAGE.md | 2 + rust/crates/runtime/src/config.rs | 165 ++++++++++++++---- rust/crates/runtime/src/config_validate.rs | 53 +++--- rust/crates/rusty-claude-cli/src/main.rs | 24 +++ .../tests/output_format_contract.rs | 157 +++++++++++++++++ 6 files changed, 345 insertions(+), 58 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f971701f..5d85e321 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6351,7 +6351,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 424. **DONE — `--model` accepts bare canonical provider model names and Anthropic routing prefixes are stripped before provider calls** — fixed 2026-06-03 in `fix: normalize Anthropic model routing`. `validate_model_syntax()` now accepts unambiguous bare `claude-*` and `gpt-*` model IDs while preserving raw model provenance, and Anthropic `/v1/messages` plus `/v1/messages/count_tokens` request bodies strip the CLI-only `anthropic/` routing prefix so default/alias models do not reach Anthropic as `anthropic/claude-*`. Existing `qwen-*`/`grok-*` prefix-hint behavior remains intentionally unchanged for provider families whose bare names are ambiguous with DashScope/xAI routing. Regression coverage: `standard_messages_body_strips_anthropic_routing_prefix`, `send_message_strips_anthropic_routing_prefix_on_wire`, `default_model_alias_uses_anthropic_routing_prefix`, and the bare `--model=claude-opus-4-6 status` / `--model gpt-4 prompt` parser assertions in `parses_single_word_command_aliases_without_falling_back_to_prompt_mode`. -425. **Config file precedence (`.claw/settings.json` always wins over `.claw.json`) is undocumented in user-facing surfaces — `config --output-format json` reports both files as `loaded:true` with no `precedence_rank` or `wins_for_keys` attribution; sibling: deprecation warning fires 4× per status invocation (was 3× in #424, regression upward)** — dogfooded 2026-05-11 by Jobdori on `d7dbe951` in response to Clawhip pinpoint nudge at `1503237744451649537`. Reproduction: create `.claw.json` with `{"model":"anthropic/claude-sonnet-4-6"}` and `.claw/settings.json` with `{"model":"anthropic/claude-opus-4-7"}` in the same workspace. `claw status --output-format json` returns `model:"anthropic/claude-opus-4-7", model_source:"config"`. Reverse the files (.claw.json=opus, settings.json=sonnet) → `model:"anthropic/claude-sonnet-4-6"`. Confirmed: `.claw/settings.json` **always** wins over `.claw.json` for conflicting keys, regardless of file mtime or alphabetical order. `claw config --output-format json` reports both as `loaded:true` with no `precedence_rank`, `effective_for_keys`, or `shadowed_keys` attribution. The only signal of precedence is the final merged value in `status` — automation cannot programmatically discover which file contributed which key without re-implementing the merge logic. **Sibling bug (regression from #424):** the `enabledPlugins` deprecation warning now fires **4 times** in stderr per single `status` invocation (was 3× in #424's probe at HEAD `6c0c305a`; current HEAD `d7dbe951` shows 4×). Config load count went up by 1. **Sibling bug observed in config-section probe:** `claw config model --output-format json` with a `.claw.json` that contains a benign unknown key (e.g., `"alpha":"x"`) returns `{"error":"/path/.claw.json: unknown key \"alpha\" (line 1)","kind":"unknown"}` — the entire config command fails with a generic `unknown` kind instead of (a) tolerating unrecognized keys with a warning, or (b) emitting a typed `kind:"unknown_key"` error scoped to the offending file/key. **Required fix shape:** (a) document precedence order in `USAGE.md` (`.claw/settings.local.json > .claw/settings.json > .claw.json` for project scope; `user`/`system` scope at each layer); (b) add `precedence_rank:int` and optional `wins_for_keys:[string]` / `shadowed_keys:[string]` to each entry in `config --output-format json` `files[]`; (c) dedupe the deprecation warning to fire **once per discovered file** instead of N× per load pass; (d) make `config
--output-format json` tolerate unknown keys with warnings, OR emit `kind:"unknown_key"` with `path:` and `key:` fields scoped to the offending file. **Why this matters:** users mixing legacy `.claw.json` with new `.claw/settings.json` have no way to verify which file is actually controlling their runtime. The undocumented precedence + missing per-key attribution forces trial-and-error to debug config drift. Cross-references #407 (config files no load_error) and #415 (config section returns merged_keys count not values). Source: Jobdori live dogfood, `d7dbe951`, 2026-05-11. +425. **DONE — config JSON exposes file precedence attribution and unknown config keys are warnings** — fixed 2026-06-03 in `fix: attribute config precedence in JSON`. Runtime config inspection now reports every discovered file with `precedence_rank`, `wins_for_keys`, and `shadowed_keys`, so `.claw/settings.json` overriding legacy `.claw.json` is visible without reimplementing merge order. Unknown keys are tolerated as structured validation warnings, including `claw config
--output-format json`, while wrong-type errors still fail. The deprecation-warning path remains deduplicated once per process for text-mode `status`, and JSON config surfaces collect warnings structurally without stderr duplication. Docs in `USAGE.md` now spell out the precedence chain and JSON attribution fields. Regression coverage: `config_json_attributes_precedence_and_shadowed_keys_425`, `config_section_json_tolerates_unknown_keys_as_warnings_425`, `status_deduplicates_config_deprecation_warnings_per_invocation_425`, and the runtime validator unknown-key warning tests. 426. **`ANTHROPIC_MODEL` env var bypasses the `invalid_model_syntax` validator that `--model` enforces — bogus model strings are accepted with `status:"ok"`, deferred-failing only when the first API call is made** — dogfooded 2026-05-11 by Jobdori on `3730b459` in response to Clawhip pinpoint nudge at `1503245298800136296`. Reproduction (asymmetric validation): `claw --model bogus-model-xyz status --output-format json` returns `kind:"invalid_model_syntax"` exit 1; `ANTHROPIC_MODEL=bogus-model-xyz claw status --output-format json` returns `model:"bogus-model-xyz", model_raw:"bogus-model-xyz", model_source:"env", status:"ok"` — the doctor surface lies that the configured model is valid when it is not. The bogus model only manifests as a failure when the first prompt fires and the API rejects it with 404/400. Three sibling discoveries in the same probe: (a) **alias indirection invisible**: `ANTHROPIC_MODEL=opus claw status --output-format json` returns `model:"claude-opus-4-6", model_raw:"opus", model_source:"env"` — the `opus` alias resolves to `claude-opus-4-6` (the *previous* frontier, not the current `claude-opus-4-7` released 2026-04-16). Users typing `opus` get yesterday's model with no warning. (b) **`CLAW_MODEL` env var silently ignored**: `CLAW_MODEL=opus claw status` shows `model:"claude-opus-4-6" model_source:"default"` — the `CLAW_MODEL` env var (the project-namespaced equivalent that users expect) does not exist; only `ANTHROPIC_MODEL` is honored. No warning when a `CLAW_*` env var that looks like it should work is set. (c) **`ANTHROPIC_DEFAULT_MODEL` also silently ignored**: the longer-named env var that some Anthropic SDKs use is not recognized. **Required fix shape:** (a) symmetric validation: `ANTHROPIC_MODEL` env value must pass the same `invalid_model_syntax` check that `--model` does, and `claw status` must return `kind:"invalid_model"` / `status:"warn"` (not `status:"ok"`) when the resolved model is unrecognized; (b) expose alias resolution in `status`: add `model_alias_resolved_to:string|null` field so automation can see `opus → claude-opus-4-6`; (c) bump the `opus` alias to `claude-opus-4-7` (current frontier) or document the alias-to-version mapping policy explicitly; (d) accept `CLAW_MODEL` and `ANTHROPIC_DEFAULT_MODEL` env vars with parity to `ANTHROPIC_MODEL`, OR emit a warning when those env vars are set but unrecognized. **Why this matters:** the most common automation pattern is `export ANTHROPIC_MODEL=...` in a shell rc file. Bogus values pass silently, alias indirection hides the actual model in use, and `CLAW_MODEL` looking like a working name but doing nothing is a footgun. Cross-references #424 (bare canonical names rejected at validator level) — together #424 + #426 make model selection inconsistent across CLI flag, env var, and alias paths. Source: Jobdori live dogfood, `3730b459`, 2026-05-11. diff --git a/USAGE.md b/USAGE.md index 3d58af1d..1d32e86b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -537,6 +537,8 @@ Runtime config is loaded in this order, with later entries overriding earlier on 4. `/.claw/settings.json` 5. `/.claw/settings.local.json` +The list is also the precedence chain: project-local settings override project settings, project settings override the legacy project `.claw.json`, and project files override user files. `claw --output-format json config` includes each discovered file's `precedence_rank`, `wins_for_keys`, and `shadowed_keys` so automation can see which file controls each effective key without reimplementing the merge order. + ## Hook configuration `hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks: diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index a532dca4..d03b5bc7 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -99,6 +99,10 @@ pub struct ConfigFileReport { pub status: ConfigFileStatus, pub reason: Option, pub detail: Option, + pub precedence_rank: usize, + pub wins_for_keys: Vec, + pub shadowed_keys: Vec, + key_paths: Vec, } /// Best-effort inspection of the config discovery and load pipeline. @@ -463,12 +467,14 @@ impl ConfigLoader { let mut files = Vec::new(); let mut load_error = None; - for entry in self.discover() { + for (index, entry) in self.discover().into_iter().enumerate() { + let precedence_rank = index + 1; if let Err(error) = crate::config_validate::check_unsupported_format(&entry.path) { let detail = error.to_string(); load_error.get_or_insert_with(|| detail.clone()); files.push(ConfigFileReport::load_error( entry, + precedence_rank, "unsupported_format", detail, )); @@ -478,18 +484,28 @@ impl ConfigLoader { let parsed = match read_optional_json_object(&entry.path) { Ok(OptionalConfigFile::Loaded(parsed)) => parsed, Ok(OptionalConfigFile::NotFound) => { - files.push(ConfigFileReport::not_found(entry)); + files.push(ConfigFileReport::not_found(entry, precedence_rank)); continue; } Ok(OptionalConfigFile::Skipped { reason, detail }) => { - files.push(ConfigFileReport::skipped(entry, reason, detail)); + files.push(ConfigFileReport::skipped( + entry, + precedence_rank, + reason, + detail, + )); continue; } Err(error) => { let reason = config_error_reason(&error).to_string(); let detail = error.to_string(); load_error.get_or_insert_with(|| detail.clone()); - files.push(ConfigFileReport::load_error(entry, reason, detail)); + files.push(ConfigFileReport::load_error( + entry, + precedence_rank, + reason, + detail, + )); continue; } }; @@ -504,6 +520,7 @@ impl ConfigLoader { load_error.get_or_insert_with(|| detail.clone()); files.push(ConfigFileReport::load_error( entry, + precedence_rank, "validation_error", detail, )); @@ -521,6 +538,7 @@ impl ConfigLoader { load_error.get_or_insert_with(|| detail.clone()); files.push(ConfigFileReport::load_error( entry, + precedence_rank, "validation_error", detail, )); @@ -532,15 +550,23 @@ impl ConfigLoader { { let detail = error.to_string(); load_error.get_or_insert_with(|| detail.clone()); - files.push(ConfigFileReport::load_error(entry, "parse_error", detail)); + files.push(ConfigFileReport::load_error( + entry, + precedence_rank, + "parse_error", + detail, + )); continue; } + let key_paths = collect_config_key_paths(&parsed.object); deep_merge_objects(&mut merged, &parsed.object); loaded_entries.push(entry.clone()); - files.push(ConfigFileReport::loaded(entry)); + files.push(ConfigFileReport::loaded(entry, precedence_rank, key_paths)); } + annotate_config_file_precedence(&mut files); + let runtime_config = match build_runtime_config(merged, loaded_entries, mcp_servers) { Ok(config) => Some(config), Err(error) => { @@ -559,47 +585,121 @@ impl ConfigLoader { } impl ConfigFileReport { - fn loaded(entry: ConfigEntry) -> Self { + fn loaded(entry: ConfigEntry, precedence_rank: usize, key_paths: Vec) -> Self { Self { entry, loaded: true, status: ConfigFileStatus::Loaded, reason: None, detail: None, + precedence_rank, + wins_for_keys: Vec::new(), + shadowed_keys: Vec::new(), + key_paths, } } - fn not_found(entry: ConfigEntry) -> Self { + fn not_found(entry: ConfigEntry, precedence_rank: usize) -> Self { Self { entry, loaded: false, status: ConfigFileStatus::NotFound, reason: Some("not_found".to_string()), detail: None, + precedence_rank, + wins_for_keys: Vec::new(), + shadowed_keys: Vec::new(), + key_paths: Vec::new(), } } - fn skipped(entry: ConfigEntry, reason: String, detail: Option) -> Self { + fn skipped( + entry: ConfigEntry, + precedence_rank: usize, + reason: String, + detail: Option, + ) -> Self { Self { entry, loaded: false, status: ConfigFileStatus::Skipped, reason: Some(reason), detail, + precedence_rank, + wins_for_keys: Vec::new(), + shadowed_keys: Vec::new(), + key_paths: Vec::new(), } } - fn load_error(entry: ConfigEntry, reason: impl Into, detail: String) -> Self { + fn load_error( + entry: ConfigEntry, + precedence_rank: usize, + reason: impl Into, + detail: String, + ) -> Self { Self { entry, loaded: false, status: ConfigFileStatus::LoadError, reason: Some(reason.into()), detail: Some(detail), + precedence_rank, + wins_for_keys: Vec::new(), + shadowed_keys: Vec::new(), + key_paths: Vec::new(), } } } +fn annotate_config_file_precedence(files: &mut [ConfigFileReport]) { + let mut winning_file_by_key = BTreeMap::new(); + for (index, file) in files.iter().enumerate() { + if !file.loaded { + continue; + } + for key in &file.key_paths { + winning_file_by_key.insert(key.clone(), index); + } + } + + for (index, file) in files.iter_mut().enumerate() { + if !file.loaded { + continue; + } + let mut wins_for_keys = Vec::new(); + let mut shadowed_keys = Vec::new(); + for key in &file.key_paths { + if winning_file_by_key.get(key).copied() == Some(index) { + wins_for_keys.push(key.clone()); + } else { + shadowed_keys.push(key.clone()); + } + } + file.wins_for_keys = wins_for_keys; + file.shadowed_keys = shadowed_keys; + } +} + +fn collect_config_key_paths(object: &BTreeMap) -> Vec { + let mut keys = Vec::new(); + for (key, value) in object { + collect_config_key_paths_for_value(key, value, &mut keys); + } + keys +} + +fn collect_config_key_paths_for_value(prefix: &str, value: &JsonValue, keys: &mut Vec) { + match value { + JsonValue::Object(object) if !object.is_empty() => { + for (key, nested) in object { + collect_config_key_paths_for_value(&format!("{prefix}.{key}"), nested, keys); + } + } + _ => keys.push(prefix.to_string()), + } +} + fn build_runtime_config( merged: BTreeMap, loaded_entries: Vec, @@ -2982,23 +3082,23 @@ mod tests { .expect("write user settings"); // when - let error = ConfigLoader::new(&cwd, &home) - .load() - .expect_err("config should fail"); + let (_config, warnings) = ConfigLoader::new(&cwd, &home) + .load_collecting_warnings() + .expect("unknown config keys should load with warnings"); // then - let rendered = error.to_string(); + let rendered = warnings.join("\n"); assert!( rendered.contains(&user_settings.display().to_string()), - "error should include file path, got: {rendered}" + "warning should include file path, got: {rendered}" ); assert!( rendered.contains("line 3"), - "error should include line number, got: {rendered}" + "warning should include line number, got: {rendered}" ); assert!( rendered.contains("telemetry"), - "error should name the offending field, got: {rendered}" + "warning should name the offending field, got: {rendered}" ); fs::remove_dir_all(root).expect("cleanup temp dir"); @@ -3020,28 +3120,23 @@ mod tests { .expect("write user settings"); // when - let error = ConfigLoader::new(&cwd, &home) - .load() - .expect_err("config should fail"); + let (_config, warnings) = ConfigLoader::new(&cwd, &home) + .load_collecting_warnings() + .expect("legacy unknown config keys should load with warnings"); // then - let rendered = error.to_string(); + let rendered = warnings.join("\n"); assert!( rendered.contains(&user_settings.display().to_string()), - "error should include file path, got: {rendered}" + "warning should include file path, got: {rendered}" ); assert!( rendered.contains("line 3"), - "error should include line number, got: {rendered}" + "warning should include line number, got: {rendered}" ); assert!( rendered.contains("allowedTools"), - "error should call out the unknown field, got: {rendered}" - ); - // allowedTools is an unknown key; validator should name it in the error - assert!( - rendered.contains("allowedTools"), - "error should name the offending field, got: {rendered}" + "warning should name the offending field, got: {rendered}" ); fs::remove_dir_all(root).expect("cleanup temp dir"); @@ -3101,19 +3196,19 @@ mod tests { fs::write(&user_settings, "{\n \"modle\": \"opus\"\n}\n").expect("write user settings"); // when - let error = ConfigLoader::new(&cwd, &home) - .load() - .expect_err("config should fail"); + let (_config, warnings) = ConfigLoader::new(&cwd, &home) + .load_collecting_warnings() + .expect("unknown config keys should load with warnings"); // then - let rendered = error.to_string(); + let rendered = warnings.join("\n"); assert!( rendered.contains("modle"), - "error should name the offending field, got: {rendered}" + "warning should name the offending field, got: {rendered}" ); assert!( rendered.contains("model"), - "error should suggest the closest known key, got: {rendered}" + "warning should suggest the closest known key, got: {rendered}" ); fs::remove_dir_all(root).expect("cleanup temp dir"); diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 4e0bd08a..bea04572 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -424,9 +424,10 @@ fn validate_object_keys( } else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) { // Deprecated key — handled separately, not an unknown-key error. } else { - // Unknown key. + // Unknown key — preserve compatibility by surfacing it as a warning + // instead of blocking otherwise valid config files. let suggestion = suggest_field(key, &known_names); - result.errors.push(ConfigDiagnostic { + result.warnings.push(ConfigDiagnostic { path: path_display.to_string(), field: field_path, line: find_key_line(source, key), @@ -605,10 +606,11 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "unknownField"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "unknownField"); assert!(matches!( - result.errors[0].kind, + result.warnings[0].kind, DiagnosticKind::UnknownKey { .. } )); } @@ -688,9 +690,10 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].line, Some(3)); - assert_eq!(result.errors[0].field, "badKey"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].line, Some(3)); + assert_eq!(result.warnings[0].field, "badKey"); } #[test] @@ -719,8 +722,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "hooks.BadHook"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "hooks.BadHook"); } #[test] @@ -785,8 +789,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "permissions.denyAll"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "permissions.denyAll"); } #[test] @@ -800,8 +805,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "sandbox.containerMode"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "sandbox.containerMode"); } #[test] @@ -815,8 +821,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "plugins.autoUpdate"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "plugins.autoUpdate"); } #[test] @@ -830,8 +837,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "oauth.secret"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "oauth.secret"); } #[test] @@ -866,8 +874,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - match &result.errors[0].kind { + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + match &result.warnings[0].kind { DiagnosticKind::UnknownKey { suggestion: Some(s), } => assert_eq!(s, "model"), @@ -878,7 +887,7 @@ mod tests { #[test] fn format_diagnostics_includes_all_entries() { // given - let source = r#"{"permissionMode": "plan", "badKey": 1}"#; + let source = r#"{"model": 42, "badKey": 1}"#; let parsed = JsonValue::parse(source).expect("valid json"); let object = parsed.as_object().expect("object"); let result = validate_config_file(object, source, &test_path()); @@ -890,7 +899,7 @@ mod tests { assert!(output.contains("warning:")); assert!(output.contains("error:")); assert!(output.contains("badKey")); - assert!(output.contains("permissionMode")); + assert!(output.contains("model")); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 381a3312..28d7e576 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -8654,6 +8654,30 @@ fn config_file_report_json(file: &ConfigFileReport) -> serde_json::Value { serde_json::Value::String(source.to_string()), ); object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded)); + object.insert( + "precedence_rank".to_string(), + serde_json::Value::Number(serde_json::Number::from(file.precedence_rank)), + ); + object.insert( + "wins_for_keys".to_string(), + serde_json::Value::Array( + file.wins_for_keys + .iter() + .cloned() + .map(serde_json::Value::String) + .collect(), + ), + ); + object.insert( + "shadowed_keys".to_string(), + serde_json::Value::Array( + file.shadowed_keys + .iter() + .cloned() + .map(serde_json::Value::String) + .collect(), + ), + ); object.insert( "status".to_string(), serde_json::Value::String(file.status.as_str().to_string()), diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 7c66b219..b8409505 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -1458,6 +1458,163 @@ fn config_json_reports_deprecations_structurally_without_stderr_duplicate_815() ); } +#[test] +fn status_deduplicates_config_deprecation_warnings_per_invocation_425() { + let root = unique_temp_dir("status-warning-dedup-425"); + let config_home = root.join("config-home"); + let home = root.join("home"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::write( + config_home.join("settings.json"), + r#"{"enabledPlugins": {}}"#, + ) + .expect("deprecated config fixture should write"); + + let envs = [ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("utf8 config home"), + ), + ("HOME", home.to_str().expect("utf8 home")), + ]; + let output = run_claw(&root, &["status"], &envs); + assert!( + output.status.success(), + "stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); + let warning_count = stderr + .matches("field \"enabledPlugins\" is deprecated") + .count(); + assert_eq!( + warning_count, 1, + "status should emit the deprecated enabledPlugins warning once per process:\n{stderr}" + ); +} + +#[test] +fn config_json_attributes_precedence_and_shadowed_keys_425() { + let root = unique_temp_dir("config-precedence-425"); + let config_home = root.join("config-home"); + let home = root.join("home"); + fs::create_dir_all(root.join(".claw")).expect("workspace config should exist"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::write( + root.join(".claw.json"), + r#"{"model":"anthropic/claude-sonnet-4-6","env":{"A":"legacy","B":"legacy"}}"#, + ) + .expect("legacy project config fixture should write"); + fs::write( + root.join(".claw").join("settings.json"), + r#"{"model":"anthropic/claude-opus-4-6","env":{"A":"settings","C":"settings"}}"#, + ) + .expect("project settings fixture should write"); + + let envs = [ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("utf8 config home"), + ), + ("HOME", home.to_str().expect("utf8 home")), + ]; + let parsed = assert_json_command_with_env(&root, &["--output-format", "json", "config"], &envs); + let files = parsed["files"].as_array().expect("files array"); + let legacy = files + .iter() + .find(|file| { + file["source"] == "project" + && file["path"] + .as_str() + .is_some_and(|path| path.ends_with(".claw.json")) + }) + .expect("project .claw.json entry"); + let settings = files + .iter() + .find(|file| { + file["source"] == "project" + && file["path"] + .as_str() + .is_some_and(|path| path.ends_with(".claw/settings.json")) + }) + .expect("project .claw/settings.json entry"); + + assert_eq!(legacy["status"], "loaded"); + assert_eq!(settings["status"], "loaded"); + assert!( + settings["precedence_rank"].as_u64().expect("settings rank") + > legacy["precedence_rank"].as_u64().expect("legacy rank"), + "later project settings must outrank legacy project config: legacy={legacy} settings={settings}" + ); + for key in ["model", "env.A"] { + assert!( + legacy["shadowed_keys"] + .as_array() + .expect("legacy shadowed keys") + .iter() + .any(|value| value.as_str() == Some(key)), + "legacy config should report {key} as shadowed: {legacy}" + ); + assert!( + settings["wins_for_keys"] + .as_array() + .expect("settings winning keys") + .iter() + .any(|value| value.as_str() == Some(key)), + "project settings should report {key} as winning: {settings}" + ); + } + assert!( + legacy["wins_for_keys"] + .as_array() + .expect("legacy winning keys") + .iter() + .any(|value| value.as_str() == Some("env.B")), + "unshadowed legacy keys should remain attributed to .claw.json: {legacy}" + ); +} + +#[test] +fn config_section_json_tolerates_unknown_keys_as_warnings_425() { + let root = unique_temp_dir("config-unknown-warning-425"); + let config_home = root.join("config-home"); + let home = root.join("home"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::write(root.join(".claw.json"), r#"{"model":"opus","alpha":"x"}"#) + .expect("legacy config fixture should write"); + + let envs = [ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("utf8 config home"), + ), + ("HOME", home.to_str().expect("utf8 home")), + ]; + let parsed = assert_json_command_with_env( + &root, + &["--output-format", "json", "config", "model"], + &envs, + ); + + assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["section"], "model"); + assert_eq!(parsed["section_value"], "opus"); + assert!( + parsed["warnings"] + .as_array() + .expect("warnings array") + .iter() + .any(|warning| warning + .as_str() + .is_some_and(|text| text.contains("unknown key \"alpha\""))), + "unknown keys should be structural warnings, not section failures: {parsed}" + ); +} + #[test] fn config_json_reports_structured_unloaded_file_reasons_407() { let root = unique_temp_dir("config-file-status-407");