Skip to content

refactor(cli): unify diagnose/probe-adapter --json with the pure-document contract - #201

Merged
pbean merged 2 commits into
mainfrom
feat/json-doc-diagnose-probe-195
Jul 19, 2026
Merged

refactor(cli): unify diagnose/probe-adapter --json with the pure-document contract#201
pbean merged 2 commits into
mainfrom
feat/json-doc-diagnose-probe-195

Conversation

@pbean

@pbean pbean commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Closes #195.

What

--json had two incompatible meanings. status/list emitted a pure JSON document; diagnose/probe-adapter appended a fenced ```json block to a markdown report, so a consumer could not json.loads the stream — it had to scrape the fence out of prose.

Both commands now emit the document instead of the report:

  • stdout parses whole, no fences.
  • Every human-facing line — ok: trailers, the leak-backstop warning, the unknown profile notice — moves to stderr.
  • --json --out FILE writes the document to the file, leaves stdout empty, and puts the confirmation on stderr.
  • Text mode (no --json) is unchanged; the human-readable report is still what users hand to maintainers.

machine.py's carve-out naming these two commands as exceptions is deleted, along with its mirrors in FEATURES.md, the adapter-authoring guide, README.md, and the bug-report issue template (which told reporters to expect a fenced block).

Schema versions

diagnostics.SCHEMA_VERSION stays at 1, deliberately. machine.py defines that number as a property of the document — it bumps when a field is removed or renamed, a type changes, or a value's meaning changes — and none of those happened here. Only the packaging changed. Bumping it would falsely tell a consumer pinned to v1 that the fields it reads are gone, while a consumer genuinely broken by the repackaging finds out immediately: the fence is gone and json.loads fails.

probe gains schema_version: 1 as its baseline, alongside its existing version key — which still holds the probed CLI's --version output and is deliberately not renamed.

Leak-guard hardening (the part worth reviewing closely)

Making diagnose --json skip render_markdown turned out to weaken the sanitizer, because json.dumps escapes. Both of these fire the guard as raw text and evaded it entirely inside the JSON document:

value raw text JSON (before)
C:\Users\alice absolute-home-path nothing\ doubled past _ABS_HOME_RE
non-ASCII username sensitive[username] nothing — escaped to \uXXXX

Previously the markdown pass caught both and refused to emit. Fixed at the source: _ABS_HOME_RE now matches the escaped separator, and render_json uses ensure_ascii=False so values reach the guard as themselves. Both have regression tests that were verified to fail when the fix is reverted.

Incidental fixes

  • diagnose --json no longer renders a markdown report it was about to discard — which was double-counting every leak-backstop repair in the operator warning (x2 for a single occurrence). The count is now pinned by an assertion.
  • The probe document is sort_keys-stable, so two probes of the same CLI diff cleanly. Pinned by a key-order assertion (a plain json.loads round-trip cannot see order).

Testing

2541 passed, 1 skipped; trunk check clean across 13 files.

Beyond the suite, each new guarantee was checked by reverting the fix and confirming the test fails — the guard hardening, the repair count, and sort_keys. Two pre-existing tests needed retargeting: they monkeypatched render_markdown to force the refusal path, which JSON mode no longer reaches, so they would have passed vacuously. They are now parametrized [text, json] and patch whichever render the mode actually uses.

Live smoke on both commands: pure document, zero fences, --out leaving stdout at 0 bytes, error paths exiting nonzero with empty stdout.

Deliberately out of scope

  • probe-adapter report has no egress leak guard and no pseudonymizer #199probe.py has no egress leak guard and no pseudonymizer. sanitize.py's own docstring notes that scrub_json passes story keys and branch names verbatim, and probe never re-scans its rendered bytes. Untouched here; this PR changes packaging, not what may appear inside the document.
  • diagnose --json can raise UnicodeEncodeError on a non-UTF-8 console #200ensure_ascii=False lets raw non-ASCII reach stdout, which can raise UnicodeEncodeError on a non-UTF-8 console. Narrow (needs non-sensitive non-ASCII and a legacy console) and fails safe: the encode precedes any write, so stdout stays empty, and --out pins encoding="utf-8". Note for reviewers: do not "fix" it by reverting ensure_ascii=False — that flag is what lets the guard see non-ASCII values at all. The issue documents the tradeoffs.

Summary by CodeRabbit

  • Changed
    • --json for status, list, diagnose, and probe-adapter now emits a pure stable JSON document to stdout; when using --out FILE, stdout is empty and only the file receives JSON.
    • Human-readable output/ok: trailers go to stderr in JSON mode, with consistent JSON across output paths.
  • Bug Fixes
    • Strengthened protection against leaking escaped home paths and non-ASCII sensitive values; JSON output now preserves UTF-8 reliably.
  • Documentation
    • Updated the command reference, features/docs, adapter guidance, and the bug-report template to match the new --json behavior.

…ment contract (#195)

`--json` had two incompatible meanings: status/list emitted a pure JSON
document, while diagnose/probe-adapter appended a fenced ```json block to a
markdown report that a consumer had to scrape out of prose.

Both now emit the document instead of the report. stdout parses whole, every
human-facing line (ok: trailers, the leak-backstop warning, the unknown-profile
notice) moves to stderr, and with --out the document goes to the file while
stdout stays empty. Text mode is unchanged.

diagnostics.SCHEMA_VERSION deliberately stays at 1 -- it versions the document,
and only the packaging changed. probe gains schema_version 1 alongside its
existing `version` key, which still holds the probed CLI's --version output.

Also hardens the leak guard. Skipping the markdown render in JSON mode turned
out to weaken it: json.dumps escapes, so a Windows home path (C:\\Users) evaded
_ABS_HOME_RE and a non-ASCII sensitive value evaded the username rule -- both
were previously caught by the markdown pass, which refused to emit. _ABS_HOME_RE
now matches the escaped separator and render_json uses ensure_ascii=False, with
regression tests that fail if either is reverted.

Incidental fixes: diagnose no longer renders a markdown report it was about to
discard (which double-counted every backstop repair in the warning), and the
probe document is sort_keys-stable so two probes diff cleanly.

Two follow-ups are filed rather than fixed here: #199 (probe has no egress leak
guard and no pseudonymizer) and #200 (ensure_ascii=False lets raw non-ASCII
reach stdout, which can raise UnicodeEncodeError on a non-UTF-8 console).
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4133de79-9c55-4964-ba38-9a1851ab1e94

📥 Commits

Reviewing files that changed from the base of the PR and between c6571bc and f204c2e.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • src/bmad_loop/cli.py
  • src/bmad_loop/machine.py
  • src/bmad_loop/sanitize.py
  • tests/test_cli.py
  • tests/test_sanitize.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/bmad_loop/sanitize.py
  • CHANGELOG.md
  • tests/test_cli.py
  • src/bmad_loop/cli.py

Walkthrough

Changes

Unified machine-readable output

Layer / File(s) Summary
JSON contract and safety enforcement
src/bmad_loop/machine.py, src/bmad_loop/probe.py, src/bmad_loop/diagnostics.py, src/bmad_loop/sanitize.py, tests/test_diagnostics.py, tests/test_cli.py, tests/test_sanitize.py
Shared JSON emission validates and preserves rendered bytes; probe documents include schema metadata and stable key ordering; diagnostics serialization and leak checks handle escaped paths and non-ASCII values.
CLI output routing and documentation
src/bmad_loop/cli.py, README.md, docs/*, CHANGELOG.md, .github/ISSUE_TEMPLATE/bug-report.yaml, tests/test_probe.py
diagnose --json and probe-adapter --json emit standalone JSON, support --out, keep stdout clean, and route human messages to stderr, with documentation and CLI coverage updated.
Diagnose failure and repair validation
tests/test_cli.py, tests/test_diagnostics.py
Diagnose tests cover refusal paths, legend handling, repairs, pure JSON output, file output, and sanitized content preservation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Poem

A rabbit hops through JSON bright,
Pure documents stream just right.
Human words go stderr’s way,
Stable keys line up and stay.
Scrubbed paths rest safe and sound.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main change: making diagnose/probe-adapter --json follow the pure-document contract.
Linked Issues check ✅ Passed The code and tests implement the pure-document stdout/stderr contract for diagnose and probe-adapter, including --out and refusal handling.
Out of Scope Changes check ✅ Passed The docs, changelog, and tests all support the same JSON-contract change; no unrelated code changes stand out.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/json-doc-diagnose-probe-195

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/bmad_loop/cli.py (1)

1948-1964: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider centralizing the --out write + trailer pattern.

cmd_probe and cmd_diagnose each re-implement "write report/document to --out (json gets + "\n") and print an ok: confirmation on the right stream, else emit to stdout" independently. Since machine.py already centralizes half of this contract (emit/emit_document), a small helper there (e.g. machine.write_or_emit(rendered, *, out_path, json_mode, ok_message)) would keep both commands' file-vs-stdout branching in one place and reduce the risk of the two commands drifting apart as the contract evolves.

♻️ Sketch
def write_or_emit(rendered: str, *, out_path: Path | None, json_mode: bool, ok_message: str, trailer_stream) -> None:
    if out_path is not None:
        out_path.write_text((rendered + "\n") if json_mode else rendered, encoding="utf-8")
        print(ok_message, file=trailer_stream)
    elif json_mode:
        emit_document(rendered)
    else:
        print(rendered)

Optional given the two call sites aren't byte-identical (differing trailer text/conditions), so weigh the abstraction against the modest duplication.

Also applies to: 2056-2072

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/cli.py` around lines 1948 - 1964, The `--out` file-writing,
JSON newline handling, stdout emission, and success trailer logic are duplicated
in `cmd_probe` and `cmd_diagnose`. Add a small helper in `machine.py` that
accepts the rendered report, optional output path, JSON mode, success message,
and trailer stream, then replace both command branches with calls to it while
preserving their existing messages and conditions.
src/bmad_loop/sanitize.py (1)

76-82: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider supporting forward slashes for Windows paths.

While the regex correctly handles the backslash doubling caused by JSON serialization, Windows paths can sometimes be normalized with forward slashes (e.g., C:/Users/alice in Git Bash, MSYS, or certain Node.js environments). You can optionally use [\\/] to catch both separator forms.

💡 Proposed refactor
- _ABS_HOME_RE = re.compile(r"/home/|/Users/|/root/|[A-Za-z]:\\{1,2}Users\\{1,2}", re.I)
+ _ABS_HOME_RE = re.compile(r"/home/|/Users/|/root/|[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}", re.I)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/sanitize.py` around lines 76 - 82, Update _ABS_HOME_RE to
recognize Windows home paths using either backslash or forward-slash separators,
while preserving support for JSON-doubled backslashes and the existing POSIX
prefixes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/bmad_loop/cli.py`:
- Around line 1948-1964: The `--out` file-writing, JSON newline handling, stdout
emission, and success trailer logic are duplicated in `cmd_probe` and
`cmd_diagnose`. Add a small helper in `machine.py` that accepts the rendered
report, optional output path, JSON mode, success message, and trailer stream,
then replace both command branches with calls to it while preserving their
existing messages and conditions.

In `@src/bmad_loop/sanitize.py`:
- Around line 76-82: Update _ABS_HOME_RE to recognize Windows home paths using
either backslash or forward-slash separators, while preserving support for
JSON-doubled backslashes and the existing POSIX prefixes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7d27e62c-e630-4f3a-ab24-5314365680de

📥 Commits

Reviewing files that changed from the base of the PR and between 3467e34 and c6571bc.

📒 Files selected for processing (13)
  • .github/ISSUE_TEMPLATE/bug-report.yaml
  • CHANGELOG.md
  • README.md
  • docs/FEATURES.md
  • docs/adapter-authoring-guide.md
  • src/bmad_loop/cli.py
  • src/bmad_loop/diagnostics.py
  • src/bmad_loop/machine.py
  • src/bmad_loop/probe.py
  • src/bmad_loop/sanitize.py
  • tests/test_cli.py
  • tests/test_diagnostics.py
  • tests/test_probe.py

`machine.emit_document` parses a document before printing it, so the
contract "stdout is either one complete valid document or empty" holds
structurally. The `--out` branch bypassed that: both commands called
`write_text` directly, so `--json --out FILE` could write a malformed
document that stdout would have refused to print — backwards, since the
file is the one nobody eyeballs before feeding it to a parser.

Add `machine.write_document`, the `--out` sibling of `emit_document`,
sharing a `_validated` parse-or-raise. It refuses before creating the
file, and appends the same trailing newline `print` would, so
`--json --out FILE` and `--json > FILE` are byte-identical — now pinned
by a test rather than by a comment duplicated at two call sites.

The trailer logic stays where it is: `cmd_probe` prints one in both
branches with different text, `cmd_diagnose` only under `--out`, so
folding those together would need a helper that fits neither.

Also pin every arm of `_ABS_HOME_RE`. The table covered only
`/home/alice/x`, leaving `/Users/`, `/root/` and the forward-slash
Windows form (`C:/Users/…`, what `Path.as_posix` and MSYS-ish tooling
produce) correct but untested — the last of these matches via the
`/Users/` arm, not the drive-letter arm, which is for backslashes only.
Verified by deleting the `/Users/` arm and watching both new cases go
red. A negative case bounds the rule in the other direction: firing is
fail-closed, so matching every absolute path would turn an ordinary
dump into a refusal.
@pbean

pbean commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Both nitpicks validated. One is a false positive; the other pointed at a real gap, though not the one it named. Pushed as f204c2e.

sanitize.py — forward slashes in _ABS_HOME_RE: declining, the case is already covered

The premise is that C:/Users/alice isn't caught. It is — the /Users/ alternative matches it as a substring, so the drive-letter arm never has to. Measured, current rule vs. the proposal:

value current proposed
C:/Users/alice/proj True True
c:/users/bob True True
C:\Users\alice (and JSON-doubled C:\\Users\\alice) True True
/c/Users/alice (git-bash) True True
C:\Users/alice (mixed separators) False True
D:/data/alice (non-home drive) False False

The only delta is the mixed-separator oddity, which has no realistic producer — and any string carrying a separator at all is rejected by looks_like_identifier upstream (_IDENTIFIER_RE admits neither / nor \) and redacted to <redacted:str> before it can reach the backstop.

Worth saying that the finding was reasonable to raise: the comment above the rule explained only the backslash-doubling rationale, so it read as if Windows were handled solely by the drive-letter arm. That was a real documentation gap even though the code was right. Fixed both ways — the comment now states why the drive-letter arm is backslash-only, and test_assert_no_leak_fires now pins /Users/, /root/ and C:/Users/… instead of just /home/alice/x. Those cases were correct only by accident; a future "simplification" of the alternation could have silently dropped them. Verified by deleting the /Users/ arm and confirming both new cases go red.

Added a negative case too (D:/data/alice, /var/lib/alice/x), because the bound matters in both directions: firing is fail-closed, so a rule that matched every absolute path would turn an ordinary dump into a refusal.

cli.py — centralizing the --out write + trailer: taking the direction, not the signature

The sketched write_or_emit(..., ok_message, trailer_stream) can't express cmd_probe, which prints a trailer in both branches with different text computed from different data (finding.mode/args.cli vs. out_path), while cmd_diagnose prints one only under --out. Threading that through would need a second message parameter used by one caller — an abstraction that fits neither site.

But the duplication was hiding something. machine.emit_document parses a document before printing it, deliberately, so "stdout is either one complete valid document or empty" holds structurally rather than by convention. The --out branch bypassed it entirely — plain write_text — so --json --out FILE could write a malformed document that stdout would have refused to print. That's backwards: the file is the one nobody eyeballs before feeding it to a parser.

So the extracted piece is the contract, not the control flow. New machine.write_document(path, rendered) is the --out sibling of emit_document, sharing a _validated parse-or-raise; it refuses before creating the file, and appends the same trailing newline print would. --json --out FILE and --json > FILE are now byte-identical — pinned by a test rather than by an explanatory comment duplicated at two call sites. Trailer logic stays exactly where it was.

Smoke-confirmed live on both commands: --out leaves stdout at 0 bytes, the file parses, no fences, and it diffs clean against the piped form.

Docstring coverage (57.78%) — no action

Chronic rather than a regression from this PR: #187 merged at 44.44%, #188 at 60.00%, #191 at 66.67%. Nothing enforces it — pyproject.toml has no [tool.ruff.lint] table at all (so no pydocstyle D rules), there's no .coderabbit.yaml opting into the 80% default, and .trunk/trunk.yaml enables no docstring linter. This PR's only genuinely new function, machine.emit_document, is fully documented; the percentage is measuring pre-existing cmd_* handlers that the repo leaves undocumented as house style, with their contracts in argparse help and module docstrings. Clearing it would mean documenting ~7 untouched functions in a style the codebase deliberately doesn't use.

2547 passed, 1 skipped (+6 from the 2541 baseline); trunk check clean across 14 files.

@pbean

pbean commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unify diagnose/probe-adapter --json with the pure-document contract

1 participant