Skip to content

Commit 727a1ea

Browse files
committed
fix(#773): config --output-format json now surfaces deprecation warnings in warnings[] array instead of only stderr text
1 parent 212f0b2 commit 727a1ea

3 files changed

Lines changed: 77 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7711,3 +7711,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
77117711
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.
77127712

77137713
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.
7714+
7715+
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<String>)` 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.

rust/crates/runtime/src/config.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,70 @@ impl ConfigLoader {
346346
feature_config,
347347
})
348348
}
349+
350+
/// Like [`load`] but also returns the list of validation warnings collected during
351+
/// loading, without emitting them to stderr. Callers that want to surface warnings
352+
/// through a structured channel (e.g. the JSON config envelope) should use this.
353+
/// #773: enables JSON-mode callers to include `warnings` in their output envelope
354+
/// instead of receiving unstructured text on stderr.
355+
pub fn load_collecting_warnings(&self) -> Result<(RuntimeConfig, Vec<String>), ConfigError> {
356+
let mut merged = BTreeMap::new();
357+
let mut loaded_entries = Vec::new();
358+
let mut mcp_servers = BTreeMap::new();
359+
let mut all_warnings: Vec<String> = Vec::new();
360+
361+
for entry in self.discover() {
362+
crate::config_validate::check_unsupported_format(&entry.path)?;
363+
let Some(parsed) = read_optional_json_object(&entry.path)? else {
364+
continue;
365+
};
366+
let validation = crate::config_validate::validate_config_file(
367+
&parsed.object,
368+
&parsed.source,
369+
&entry.path,
370+
);
371+
if !validation.is_ok() {
372+
let first_error = &validation.errors[0];
373+
return Err(ConfigError::Parse(first_error.to_string()));
374+
}
375+
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
376+
validate_optional_hooks_config(&parsed.object, &entry.path)?;
377+
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)?;
378+
deep_merge_objects(&mut merged, &parsed.object);
379+
loaded_entries.push(entry);
380+
}
381+
382+
// Still emit to stderr for non-JSON callers that go through the normal load() path;
383+
// here we just *also* return them so callers can surface them structurally.
384+
for warning in &all_warnings {
385+
emit_config_warning_once(warning);
386+
}
387+
388+
let merged_value = JsonValue::Object(merged.clone());
389+
390+
let feature_config = RuntimeFeatureConfig {
391+
hooks: parse_optional_hooks_config(&merged_value)?,
392+
plugins: parse_optional_plugin_config(&merged_value)?,
393+
mcp: McpConfigCollection {
394+
servers: mcp_servers,
395+
},
396+
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
397+
model: parse_optional_model(&merged_value),
398+
aliases: parse_optional_aliases(&merged_value)?,
399+
permission_mode: parse_optional_permission_mode(&merged_value)?,
400+
permission_rules: parse_optional_permission_rules(&merged_value)?,
401+
sandbox: parse_optional_sandbox_config(&merged_value)?,
402+
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
403+
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
404+
};
405+
406+
let config = RuntimeConfig {
407+
merged,
408+
loaded_entries,
409+
feature_config,
410+
};
411+
Ok((config, all_warnings))
412+
}
349413
}
350414

351415
impl RuntimeConfig {

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7874,7 +7874,9 @@ fn render_config_json(
78747874
let cwd = env::current_dir()?;
78757875
let loader = ConfigLoader::default_for(&cwd);
78767876
let discovered = loader.discover();
7877-
let runtime_config = loader.load()?;
7877+
// #773: use load_collecting_warnings so deprecation warnings are surfaced in the
7878+
// JSON envelope instead of only as unstructured stderr text.
7879+
let (runtime_config, config_warnings) = loader.load_collecting_warnings()?;
78787880

78797881
let loaded_paths: Vec<_> = runtime_config
78807882
.loaded_entries()
@@ -7902,6 +7904,11 @@ fn render_config_json(
79027904
})
79037905
.collect();
79047906

7907+
let warnings_json: Vec<serde_json::Value> = config_warnings
7908+
.iter()
7909+
.map(|w| serde_json::Value::String(w.clone()))
7910+
.collect();
7911+
79057912
let base = serde_json::json!({
79067913
"kind": "config",
79077914
"action": if section.is_some() { "show" } else { "list" },
@@ -7910,6 +7917,9 @@ fn render_config_json(
79107917
"loaded_files": loaded_paths.len(),
79117918
"merged_keys": runtime_config.merged().len(),
79127919
"files": files,
7920+
// #773: deprecation warnings surfaced structurally so JSON-mode callers
7921+
// don't need to strip unstructured text from stderr
7922+
"warnings": warnings_json,
79137923
});
79147924

79157925
if let Some(section) = section {

0 commit comments

Comments
 (0)