From 517d7e224eec1948562484945658036d92d95ace Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 22 Apr 2026 20:02:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#178=20=E2=80=94=20argparse=20errors=20?= =?UTF-8?q?emit=20JSON=20envelope=20when=20--output-format=20json=20reques?= =?UTF-8?q?ted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfood pinpoint: running 'claw nonexistent-command --output-format json' bypasses the JSON envelope contract — argparse dumps human-readable usage to stderr with exit 2, breaking the SCHEMAS.md guarantee that JSON mode returns structured output. Problem: $ claw nonexistent --output-format json usage: main.py [-h] {summary,manifest,...} ... main.py: error: argument command: invalid choice: 'nonexistent' (choose from ...) [exit 2 — no envelope, claws must parse argparse usage messages] Fix: $ claw nonexistent --output-format json { "timestamp": "2026-04-22T11:00:29Z", "command": "nonexistent-command", "exit_code": 1, "output_format": "json", "schema_version": "1.0", "error": { "kind": "parse", "operation": "argparse", "target": "nonexistent-command", "retryable": false, "message": "invalid command or argument (argparse rejection)", "hint": "run with no arguments to see available subcommands" } } [exit 1, clean JSON envelope on stdout per SCHEMAS.md] Changes: - src/main.py: - _wants_json_output(argv): pre-scan for --output-format json before parsing - _emit_parse_error_envelope(argv, message): emit wrapped envelope on stdout - main(): catch SystemExit from argparse; if JSON requested, emit envelope instead of letting argparse's help dump go through - tests/test_parse_error_envelope.py (new, 9 tests): - TestParseErrorJsonEnvelope (7): unknown command, =syntax, text mode unchanged, invalid flag, missing command, valid command unaffected, common fields - TestParseErrorSchemaCompliance (2): error.kind='parse', retryable=false Contract: - text mode (default): unchanged — argparse dumps help to stderr, exits 2 - JSON mode: envelope per SCHEMAS.md, error.kind='parse', exit 1 - Parse errors always retryable=false (typo won't self-fix) - error.kind='parse' already enumerated in SCHEMAS.md (no schema changes) This closes a real gap: claws invoking unknown commands in JSON mode can now route via exit code + envelope.kind='parse' instead of scraping argparse output. Test results: 192 → 201 passing, 3 skipped unchanged, zero regression. Pinpoint discovered via dogfood at 2026-04-22 19:59 KST (cycle #19). --- src/main.py | 52 +++++++++- tests/test_parse_error_envelope.py | 159 +++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 tests/test_parse_error_envelope.py diff --git a/src/main.py b/src/main.py index 05b2122..6727aa3 100644 --- a/src/main.py +++ b/src/main.py @@ -203,9 +203,59 @@ def build_parser() -> argparse.ArgumentParser: return parser +def _emit_parse_error_envelope(argv: list[str], message: str) -> None: + """#178: emit JSON envelope for argparse-level errors when --output-format json is requested. + + Pre-scans argv for --output-format json. If found, prints a parse-error envelope + to stdout (per SCHEMAS.md 'error' envelope shape) instead of letting argparse + dump help text to stderr. This preserves the JSON contract for claws that can't + parse argparse usage messages. + """ + import json + # Extract the attempted command (argv[0] is the first positional) + attempted = argv[0] if argv and not argv[0].startswith('-') else '' + envelope = wrap_json_envelope( + { + 'error': { + 'kind': 'parse', + 'operation': 'argparse', + 'target': attempted, + 'retryable': False, + 'message': message, + 'hint': 'run with no arguments to see available subcommands', + }, + }, + command=attempted, + exit_code=1, + ) + print(json.dumps(envelope)) + + +def _wants_json_output(argv: list[str]) -> bool: + """#178: check if argv contains --output-format json anywhere (for parse-error routing).""" + for i, arg in enumerate(argv): + if arg == '--output-format' and i + 1 < len(argv) and argv[i + 1] == 'json': + return True + if arg == '--output-format=json': + return True + return False + + def main(argv: list[str] | None = None) -> int: + import sys + if argv is None: + argv = sys.argv[1:] parser = build_parser() - args = parser.parse_args(argv) + # #178: catch argparse errors and emit JSON envelope when --output-format json present + try: + args = parser.parse_args(argv) + except SystemExit as exc: + # argparse exits with SystemExit on error (code 2) or --help (code 0) + if exc.code != 0 and _wants_json_output(argv): + # Reconstruct a generic parse-error message + _emit_parse_error_envelope(argv, 'invalid command or argument (argparse rejection)') + return 1 + raise manifest = build_port_manifest() if args.command == 'summary': print(QueryEnginePort(manifest).render_summary()) diff --git a/tests/test_parse_error_envelope.py b/tests/test_parse_error_envelope.py new file mode 100644 index 0000000..3efe7c1 --- /dev/null +++ b/tests/test_parse_error_envelope.py @@ -0,0 +1,159 @@ +"""#178 — argparse-level errors emit JSON envelope when --output-format json is requested. + +Before #178: + $ claw nonexistent --output-format json + usage: main.py [-h] {summary,manifest,...} ... + main.py: error: argument command: invalid choice: 'nonexistent' (choose from ...) + [exit 2, argparse dumps help to stderr, no JSON envelope] + +After #178: + $ claw nonexistent --output-format json + {"timestamp": "...", "command": "nonexistent", "exit_code": 1, ..., + "error": {"kind": "parse", "operation": "argparse", ...}} + [exit 1, JSON envelope on stdout, matches SCHEMAS.md contract] + +Contract: +- text mode: unchanged (argparse still dumps help to stderr, exit code 2) +- JSON mode: envelope matches SCHEMAS.md 'error' shape, exit code 1 +- Parse errors use error.kind='parse' (distinct from runtime/session/etc.) +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +CLI = [sys.executable, '-m', 'src.main'] +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class TestParseErrorJsonEnvelope: + """Argparse errors emit JSON envelope when --output-format json is requested.""" + + def test_unknown_command_json_mode_emits_envelope(self) -> None: + """Unknown command + --output-format json → parse-error envelope.""" + result = subprocess.run( + CLI + ['nonexistent-command', '--output-format', 'json'], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 1, f"expected exit 1; got {result.returncode}" + envelope = json.loads(result.stdout) + # Common fields + assert envelope['schema_version'] == '1.0' + assert envelope['output_format'] == 'json' + assert envelope['exit_code'] == 1 + # Error envelope shape + assert envelope['error']['kind'] == 'parse' + assert envelope['error']['operation'] == 'argparse' + assert envelope['error']['retryable'] is False + assert envelope['error']['target'] == 'nonexistent-command' + assert 'hint' in envelope['error'] + + def test_unknown_command_json_equals_syntax(self) -> None: + """--output-format=json syntax also works.""" + result = subprocess.run( + CLI + ['nonexistent-command', '--output-format=json'], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 1 + envelope = json.loads(result.stdout) + assert envelope['error']['kind'] == 'parse' + + def test_unknown_command_text_mode_unchanged(self) -> None: + """Text mode (default) preserves argparse behavior: help to stderr, exit 2.""" + result = subprocess.run( + CLI + ['nonexistent-command'], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 2, f"text mode must preserve argparse exit 2; got {result.returncode}" + # stderr should have argparse error (help + error message) + assert 'invalid choice' in result.stderr + # stdout should be empty (no JSON leaked) + assert result.stdout == '' + + def test_invalid_flag_json_mode_emits_envelope(self) -> None: + """Invalid flag at top level + --output-format json → envelope.""" + result = subprocess.run( + CLI + ['--invalid-top-level-flag', '--output-format', 'json'], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + # argparse might reject before --output-format is parsed; still emit envelope + assert result.returncode == 1, f"got {result.returncode}: {result.stderr}" + envelope = json.loads(result.stdout) + assert envelope['error']['kind'] == 'parse' + + def test_missing_command_no_json_flag_behaves_normally(self) -> None: + """No --output-format flag + missing command → normal argparse behavior.""" + result = subprocess.run( + CLI, + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + # argparse exits 2 when required subcommand is missing + assert result.returncode == 2 + assert 'required' in result.stderr.lower() or 'the following arguments are required' in result.stderr.lower() + + def test_valid_command_unaffected(self) -> None: + """Valid commands still work normally (no regression).""" + result = subprocess.run( + CLI + ['list-sessions', '--output-format', 'json'], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + envelope = json.loads(result.stdout) + assert envelope['command'] == 'list-sessions' + assert 'sessions' in envelope + + def test_parse_error_envelope_contains_common_fields(self) -> None: + """Parse-error envelope must include all common fields per SCHEMAS.md.""" + result = subprocess.run( + CLI + ['bogus', '--output-format', 'json'], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + envelope = json.loads(result.stdout) + # All common fields required by SCHEMAS.md + for field in ('timestamp', 'command', 'exit_code', 'output_format', 'schema_version'): + assert field in envelope, f"common field '{field}' missing from parse-error envelope" + + +class TestParseErrorSchemaCompliance: + """Parse-error envelope matches SCHEMAS.md error shape.""" + + def test_error_kind_is_parse(self) -> None: + """error.kind='parse' distinguishes argparse errors from runtime errors.""" + result = subprocess.run( + CLI + ['unknown', '--output-format', 'json'], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + envelope = json.loads(result.stdout) + assert envelope['error']['kind'] == 'parse' + + def test_error_retryable_false(self) -> None: + """Parse errors are never retryable (typo won't magically fix itself).""" + result = subprocess.run( + CLI + ['unknown', '--output-format', 'json'], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + envelope = json.loads(result.stdout) + assert envelope['error']['retryable'] is False