feat: #178 — argparse errors emit JSON envelope when --output-format json requested

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).
This commit is contained in:
YeonGyu-Kim 2026-04-22 20:02:39 +09:00
parent c73423871b
commit 517d7e224e
2 changed files with 210 additions and 1 deletions

View File

@ -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 '<missing>'
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())

View File

@ -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