From 87f4334728174041b08e7383069ed2c17450a0fd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 08:36:41 +0900 Subject: [PATCH] fix(#785): add unknown_subcommand classifier arm for unknown subcommand: prose prefix --- ROADMAP.md | 2 ++ rust/crates/rusty-claude-cli/src/main.rs | 8 +++++ .../tests/output_format_contract.rs | 35 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index ace72b24..4ed1f43b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7735,3 +7735,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 783. **`claw --output-format json init` success envelope was missing `hint` field; idempotent re-init was not structurally detectable** — dogfooded 2026-05-27 on `32c9276f`. The init JSON envelope had no `hint` field (absent, not null), and no field to distinguish a fresh init from a re-init without checking `created.len() == 0`. Orchestrators had to inspect `created` array length to detect idempotent behavior. Fix: (1) added `hint` field to init JSON envelope — fresh path points at `CLAUDE.md + doctor`; idempotent path says "already initialised, run doctor"; (2) added `already_initialized: bool` field — `true` when `created` and `updated` are both empty (all artifacts skipped). Both test cases (fresh + re-init) covered by `init_json_envelope_has_hint_and_already_initialized_783`. 42 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori init-envelope probe on `32c9276f`, 2026-05-27. 784. **`claw export` had two opaque arg-error paths returning `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `81fe0ccb` (pinpoint by Gaebal-gajae). `claw export --output` (missing flag value) emitted plain `"missing value for --output"` with no typed prefix; `claw export a.md b.md` (extra positional) emitted plain `"unexpected export argument: second.md"`. Both classified as `unknown+null`. Fix: (1) `--output` missing-value error now uses `missing_flag_value:` prefix + `\n` usage hint; (2) extra positional now uses `unexpected_extra_args:` prefix + `\n` usage hint; (3) classifier `unexpected_extra_args` arm extended to match both `starts_with("unexpected extra arguments")` (prose form, #766) and `starts_with("unexpected_extra_args:")` (typed prefix form, #784). Integration test `export_arg_errors_have_typed_kind_and_hint_784` covers both paths. 43 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `81fe0ccb`, 2026-05-27. + +785. **`claw dump` (typo/near-miss for dump-manifests) returned `error_kind:"unknown"` — no classifier arm for `"unknown subcommand:"` prose prefix** — dogfooded 2026-05-27 on `e628b4bb`. Any unknown top-level subcommand that triggers the suggestion path emitted `"unknown subcommand: .\nDid you mean "` but `classify_error_kind` had no arm for that prefix; all fell to the `"unknown"` catch-all. The hint was non-null (the suggestion text was extracted by `split_error_hint`) but `error_kind` was undifferentiated. Fix: added `starts_with("unknown subcommand:")` → `"unknown_subcommand"` arm. Unit test assertion + integration test `unknown_subcommand_returns_typed_kind_785` using `claw dump` as the trigger. 44 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori subcommand-classifier probe on `e628b4bb`, 2026-05-27. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index b7eac2b6..593e43d4 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -349,6 +349,9 @@ fn classify_error_kind(message: &str) -> &'static str { } else if message.contains("has been removed.") { // #765: removed subcommands (login, logout) — hint contains migration guidance "removed_subcommand" + } else if message.starts_with("unknown subcommand:") { + // #785: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?) + "unknown_subcommand" } else if message.starts_with("unexpected extra arguments") || message.starts_with("unexpected_extra_args:") { @@ -13009,6 +13012,11 @@ mod tests { classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), "cli_parse" ); + // #785: unknown top-level subcommand (typo or unrecognised command) + assert_eq!( + classify_error_kind("unknown subcommand: dump.\nDid you mean dump-manifests"), + "unknown_subcommand" + ); assert_eq!( classify_error_kind("unsupported ACP invocation. Use `claw acp`."), "unsupported_acp_invocation" 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 d8a79123..ec8529f1 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -2502,3 +2502,38 @@ fn export_arg_errors_have_typed_kind_and_hint_784() { "hint must reference export usage, got: {h2:?}" ); } + +#[test] +fn unknown_subcommand_returns_typed_kind_785() { + // #785: `claw dump` (a near-miss for dump-manifests) returned error_kind:"unknown" + // because the classifier had no arm for "unknown subcommand:" prose prefix. + // Fix: added "unknown_subcommand" arm in classify_error_kind. + let root = unique_temp_dir("unknown-subcommand-785"); + fs::create_dir_all(&root).expect("temp dir"); + std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .output() + .ok(); + + // "dump" is close enough to "dump-manifests" to trigger the typo suggestion path + let output = run_claw(&root, &["--output-format", "json", "dump"], &[]); + assert!(!output.status.success(), "unknown subcommand should fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + let j: serde_json::Value = stderr + .lines() + .find(|l| l.trim_start().starts_with('{')) + .and_then(|l| serde_json::from_str(l).ok()) + .expect("unknown subcommand should emit JSON error"); + assert_eq!( + j["error_kind"], "unknown_subcommand", + "unknown subcommand should return unknown_subcommand kind, got {:?}", + j["error_kind"] + ); + // hint should point at the suggestion and/or --help + let hint = j["hint"].as_str().unwrap_or(""); + assert!( + hint.contains("dump-manifests") || hint.contains("--help") || hint.contains("claw"), + "hint should reference the suggested subcommand or help, got: {hint:?}" + ); +}