Avoid duplicate config warnings for JSON consumers (#3190)

JSON config output already carries collected config diagnostics in warnings[], so prose stderr emission must be reserved for text/local paths. Lazy permission-mode default resolution prevents an earlier config load from leaking the same deprecation before the JSON renderer runs.\n\nConstraint: ROADMAP #815 requires text mode to keep human stderr warnings while JSON config/list suppresses duplicate app-level config prose.\nRejected: Filtering all stderr in JSON mode | would hide cargo/compiler or unrelated diagnostics outside the app config warning path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep load_collecting_warnings side-effect-free; use load() for human stderr emission.\nTested: cargo fmt; cargo test -p rusty-claude-cli --test output_format_contract config_json_reports_deprecations_structurally_without_stderr_duplicate_815; cargo test -p rusty-claude-cli --test output_format_contract; manual target/debug/claw JSON config fixture.\nNot-tested: cargo clippy -p rusty-claude-cli --all-targets -- -D warnings is blocked by pre-existing runtime dead_code/trident warnings.
This commit is contained in:
Bellman 2026-05-28 18:09:59 +09:00 committed by GitHub
parent c3e7b6af60
commit 89e7f415a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 69 additions and 11 deletions

View File

@ -379,12 +379,6 @@ impl ConfigLoader {
loaded_entries.push(entry);
}
// Still emit to stderr for non-JSON callers that go through the normal load() path;
// here we just *also* return them so callers can surface them structurally.
for warning in &all_warnings {
emit_config_warning_once(warning);
}
let merged_value = JsonValue::Object(merged.clone());
let feature_config = RuntimeFeatureConfig {

View File

@ -1157,7 +1157,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
return action;
}
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
// Keep config-backed defaults lazy so pure-local JSON surfaces (notably
// `claw --output-format json config`) can report config warnings
// structurally without an earlier default-resolution load writing prose
// warnings to stderr.
let permission_mode = || permission_mode_override.unwrap_or_else(default_permission_mode);
match rest[0].as_str() {
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
@ -1301,7 +1305,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
output_format,
allowed_tools,
permission_mode,
permission_mode: permission_mode(),
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
@ -1338,7 +1342,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
output_format,
allowed_tools,
permission_mode,
permission_mode: permission_mode(),
compact,
base_commit: base_commit.clone(),
reasoning_effort: reasoning_effort.clone(),
@ -1350,7 +1354,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
output_format,
allowed_tools,
permission_mode,
permission_mode(),
compact,
base_commit,
reasoning_effort,
@ -1389,7 +1393,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
output_format,
allowed_tools,
permission_mode,
permission_mode: permission_mode(),
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),

View File

@ -1259,6 +1259,66 @@ fn inventory_commands_deduplicate_config_deprecation_warnings_per_process() {
}
}
#[test]
fn config_json_reports_deprecations_structurally_without_stderr_duplicate_815() {
let root = unique_temp_dir("config-json-warning-815");
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, &["--output-format", "json", "config"], &envs);
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid json");
let warnings = parsed["warnings"]
.as_array()
.expect("config JSON should include warnings[]");
assert!(
warnings.iter().any(|warning| warning
.as_str()
.is_some_and(|text| text.contains("field \"enabledPlugins\" is deprecated"))),
"config JSON warnings[] should include enabledPlugins deprecation: {parsed}"
);
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
assert!(
!stderr.contains("field \"enabledPlugins\" is deprecated"),
"JSON config should not duplicate collected config deprecations on stderr:\n{stderr}"
);
let text_output = run_claw(&root, &["config"], &envs);
assert!(
text_output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&text_output.stdout),
String::from_utf8_lossy(&text_output.stderr)
);
let text_stderr = String::from_utf8(text_output.stderr).expect("stderr utf8");
assert!(
text_stderr.contains("field \"enabledPlugins\" is deprecated"),
"text config should keep human-readable config warnings on stderr"
);
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}