From 727a1ea4a36e5be152cee36efee243addcb8a471 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 03:05:14 +0900 Subject: [PATCH] fix(#773): config --output-format json now surfaces deprecation warnings in warnings[] array instead of only stderr text --- ROADMAP.md | 2 + rust/crates/runtime/src/config.rs | 64 ++++++++++++++++++++++++ rust/crates/rusty-claude-cli/src/main.rs | 12 ++++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a5380114..abb8938d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7711,3 +7711,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 771. **`init extraarg` silently succeeded; `usage`/`stats`/`fork` with args fell to credential check** — dogfooded 2026-05-27 on `3a1d8838`. Two distinct gaps: (1) `claw init extraarg` returned `status:ok` with trailing positional ignored — `"init"` arm always returned `Ok(CliAction::Init)` regardless of `rest[1..]`; (2) `claw usage extra`, `claw stats extra`, `claw fork newbranch` had no match arms and fell to `CliAction::Prompt` + credential gate. Fixes: (1) added extra-arg check in `"init"` arm — rejects with `unexpected_extra_args:` prefix + `\n` usage hint; (2) added `"usage"`, `"stats"`, `"fork"` interactive-only arms. All four now return correct `error_kind` + non-null hint. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori sweep on `3a1d8838`, 2026-05-27. 772. **Slash command aliases bypassed `bare_slash_command_guidance` lookup** — dogfooded 2026-05-27 on `bf212b98`. `bare_slash_command_guidance()` only checked `spec.name == command_name`, not `spec.aliases`, so `claw yes`, `claw no`, `claw y`, `claw n`, `claw skill`, `claw cwd` all fell through (either to typo suggestions or `missing_credentials`). Should have returned `interactive_only:` guidance referencing the canonical form. Fix: (1) lookup changed to `spec.name == command_name || spec.aliases.contains(&command_name)`; (2) capture `canonical_name = slash_command.name`; (3) guidance strings updated to reference canonical form in remediation (e.g., `claw yes → /approve`, `claw n → /deny`, `claw skill → /skills`). 36 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint on `bf212b98`, 2026-05-27. + +773. **Config deprecation warnings only emitted as unstructured stderr text in `--output-format json` mode** — dogfooded 2026-05-27 on `212f0b2a`. `emit_config_warning_once()` always wrote to stderr regardless of output format, causing JSON-mode callers to receive an unexpected `warning: ...` text line on stderr before the JSON object. Callers had to implement ad-hoc stripping. Fix: added `ConfigLoader::load_collecting_warnings()` method that returns `(RuntimeConfig, Vec)` so callers can surface warnings structurally; `render_config_json()` now uses this and includes a `warnings: []` array in the config JSON envelope. Existing `load()` path unchanged (still emits to stderr for text-mode callers). 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori startup-friction probe on `212f0b2a`, 2026-05-27. diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 7b6e6525..cacfe42a 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -346,6 +346,70 @@ impl ConfigLoader { feature_config, }) } + + /// Like [`load`] but also returns the list of validation warnings collected during + /// loading, without emitting them to stderr. Callers that want to surface warnings + /// through a structured channel (e.g. the JSON config envelope) should use this. + /// #773: enables JSON-mode callers to include `warnings` in their output envelope + /// instead of receiving unstructured text on stderr. + pub fn load_collecting_warnings(&self) -> Result<(RuntimeConfig, Vec), ConfigError> { + let mut merged = BTreeMap::new(); + let mut loaded_entries = Vec::new(); + let mut mcp_servers = BTreeMap::new(); + let mut all_warnings: Vec = Vec::new(); + + for entry in self.discover() { + crate::config_validate::check_unsupported_format(&entry.path)?; + let Some(parsed) = read_optional_json_object(&entry.path)? else { + continue; + }; + let validation = crate::config_validate::validate_config_file( + &parsed.object, + &parsed.source, + &entry.path, + ); + if !validation.is_ok() { + let first_error = &validation.errors[0]; + return Err(ConfigError::Parse(first_error.to_string())); + } + all_warnings.extend(validation.warnings.iter().map(|w| w.to_string())); + validate_optional_hooks_config(&parsed.object, &entry.path)?; + merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)?; + deep_merge_objects(&mut merged, &parsed.object); + 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 { + hooks: parse_optional_hooks_config(&merged_value)?, + plugins: parse_optional_plugin_config(&merged_value)?, + mcp: McpConfigCollection { + servers: mcp_servers, + }, + oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, + model: parse_optional_model(&merged_value), + aliases: parse_optional_aliases(&merged_value)?, + permission_mode: parse_optional_permission_mode(&merged_value)?, + permission_rules: parse_optional_permission_rules(&merged_value)?, + sandbox: parse_optional_sandbox_config(&merged_value)?, + provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, + trusted_roots: parse_optional_trusted_roots(&merged_value)?, + }; + + let config = RuntimeConfig { + merged, + loaded_entries, + feature_config, + }; + Ok((config, all_warnings)) + } } impl RuntimeConfig { diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9290a6a5..a5e64aaa 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -7874,7 +7874,9 @@ fn render_config_json( let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let discovered = loader.discover(); - let runtime_config = loader.load()?; + // #773: use load_collecting_warnings so deprecation warnings are surfaced in the + // JSON envelope instead of only as unstructured stderr text. + let (runtime_config, config_warnings) = loader.load_collecting_warnings()?; let loaded_paths: Vec<_> = runtime_config .loaded_entries() @@ -7902,6 +7904,11 @@ fn render_config_json( }) .collect(); + let warnings_json: Vec = config_warnings + .iter() + .map(|w| serde_json::Value::String(w.clone())) + .collect(); + let base = serde_json::json!({ "kind": "config", "action": if section.is_some() { "show" } else { "list" }, @@ -7910,6 +7917,9 @@ fn render_config_json( "loaded_files": loaded_paths.len(), "merged_keys": runtime_config.merged().len(), "files": files, + // #773: deprecation warnings surfaced structurally so JSON-mode callers + // don't need to strip unstructured text from stderr + "warnings": warnings_json, }); if let Some(section) = section {