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())