feat(tui): render validate --json in a findings modal (#210)#215
Conversation
The pure seams the validate modal will be built from. No user-visible change: nothing here is wired to the app yet. launch.run_captured_streams keeps stdout and stderr apart, because the merged form is unparseable — run_captured appends stderr *after* stdout, so one DeprecationWarning from any dependency turns json.loads into "Extra data" and silently degrades the JSON path to the text one on whichever machines happen to emit it. run_captured now delegates and merges, so there is one subprocess.run. Decoding is pinned to UTF-8 at errors="replace" rather than text=True, which decodes with the locale encoding at errors="strict" (the #200 failure family, already fixed CLI-side). widgets gains validate_document (parse + version pin + shape gate, returning None rather than raising so the caller's degrade is a value check on a worker thread), validate_findings and validate_header. _RENDERS_VALIDATE_SCHEMA is a hand-written literal, deliberately not an import of documents.VALIDATE_SCHEMA_VERSION: an import would auto-follow a CLI bump and render a v2 document through a v1 renderer with the suite green. A tripwire test fails the moment the two diverge. Detail rendering is depth-2 with a json.dumps leaf, not recursive — enough for every shape the check sites actually emit (scalar, list[str], dict[str,str], list[dict]), including the nested adapters dict policy emits on the *passing* path, which a naive renderer prints as a Python repr. Findings render as one grid so check ids align across rows; the grid keeps multi-line messages (PyYAML MarkedYAMLError in a str(e)) in their column, and overflow="fold" keeps long paths from truncating. Rendering is defensive per finding: one malformed entry is a placeholder row, not a dead modal. Tests: a conftest builder producing real documents through ValidationReport -> _validate_document (so its assert rejects invented check ids), the version-pin tripwire, and a bridge test that renders a live `validate --json` on both the rc-0 and rc-1 legs and asserts no Python repr leaked — zero fixture maintenance, so a check site that starts carrying a new detail shape is covered when it ships.
Pressing `v` now renders the document instead of the text stream. The verdict is the document's own `ok` rather than the exit code, which cannot tell "the checks failed" from "the command broke"; severities are styled rather than string prefixes; and each check's `detail` — the data it knew before flattening itself into a sentence — is reachable at all, inline for warnings and problems and one `d` away for the rest. A failure adds the chained-gates footer, so a short findings list after one is not read as a short list of problems. _show_validate is a sibling worker, not a change to _show_captured, which still serves the two dry runs — they have no document to parse. Its body is wrapped because @work(thread=True) defaults to exit_on_error=True: a JSONDecodeError or a KeyError escaping there would take the whole app down rather than this one modal. exit_on_error=False is not the fix — it trades the crash for pressing `v` and nothing happening. The degrade RE-RUNS validate in text mode rather than showing the JSON it captured. Dumping the document would withhold a perfectly good human rendering at the exact moment the structural one failed and hand the reader a wall of {"schema_version": ...} instead; one sub-second subprocess on a path that should never fire buys a fallback that is byte-for-byte the pre-#210 behavior. Verified live on a real project: forcing validate_document to None reproduces the old modal exactly. The modal's __init__ only stores the document — the worker constructs it on a thread, so the grid is built in compose, on the main thread. The header is a Static holding a Text, never an f-string Label: spec_folder is user-controlled and both Static and Label default to markup=True, so a folder named docs/[wip]-epic-3 would be a MarkupError. #dialog pins an explicit height because BaseDialog is height:auto, which would make the 1fr findings body degenerate. Tests: test_validate_shows_output_modal is migrated, not adapted — its run_captured stub goes dead the moment validate calls run_captured_streams, and a dead stub is a real subprocess rather than a failure. The new stub_validate covers both legs for the same reason: stubbing only the JSON one leaves the degrade's run_captured live. Coverage is the four degrade shapes, the toggle, a raising subprocess (the exit_on_error trap), and both verdicts with rc disagreeing with what the old code would have inferred from it.
The finding has always read as a failure — "external mux backend 'x' failed to load: ..." — while carrying severity ok, so a broken third-party adapter counted as a passing check. It was pinned there only because promoting it inserts " warning: " into shipped text the TUI rendered verbatim; after phase 2 the TUI reads the document and styles from the severity field, so the severity is free to say what the message already does. `bmad-loop mux` has always printed this same condition as `warning:` on stderr — validate was the outlier. It stays below problem deliberately: selection degrades past a broken external (a failed external can never be the selected backend), so the preflight outcome above it is authoritative and neither the verdict nor rc may flip. The comment at the site argued the finding was advisory, which was the "ok" justification; it now records what is actually true. No schema bump. On an affected host counts shifts by one (warning +1, ok -1) while ok and rc do not, and machine.py's "meaning of a value" clause could be read to cover that — but documents.py contracts the check id as the matchable identity, not any given check's outcome. Under the other reading every future severity adjustment is a breaking change. The CLI text does change, and gets uglier: render() preserves the double prefix by design, so the line becomes " ok: warning: external mux backend ...". #210 unblocks the TUI half of the freeze, not the CLI half. test_validation_report_renders_each_severity_verbatim stays green (it builds its own findings), so the new test asserts the now-shipping line at the real site — nothing pinned this finding's severity before, since test_external_backends.py only covers external_backend_errors contents.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughThe TUI now runs ChangesValidate TUI and reporting
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant BmadLoopApp
participant run_captured_streams
participant validate_document
participant ValidateFindingsModal
participant TextOutputModal
BmadLoopApp->>run_captured_streams: run validate --json
run_captured_streams-->>BmadLoopApp: return code, stdout, stderr
BmadLoopApp->>validate_document: parse stdout
validate_document-->>BmadLoopApp: validated document or None
alt document is valid
BmadLoopApp->>ValidateFindingsModal: push structured findings
else document is unrenderable
BmadLoopApp->>run_captured_streams: rerun validate without --json
run_captured_streams-->>BmadLoopApp: return fallback output
BmadLoopApp->>TextOutputModal: push fallback output
end
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.
Inline comments:
In `@src/bmad_loop/tui/app.py`:
- Around line 990-995: Guard the degrade path in the validate worker by wrapping
launch.run_captured(tail) in the same exception handling used for the JSON
execution. If the text rerun raises, handle it without allowing the worker
exception to escape or the app to exit, and preserve the existing
TextOutputModal behavior for successful reruns.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b486f2d6-e1b5-400b-b2fe-2eb4787ed04f
📒 Files selected for processing (11)
CHANGELOG.mddocs/tui-guide.mdsrc/bmad_loop/cli.pysrc/bmad_loop/tui/app.pysrc/bmad_loop/tui/launch.pysrc/bmad_loop/tui/screens/modals.pysrc/bmad_loop/tui/widgets.pytests/conftest.pytests/test_cli.pytests/test_tui_app.pytests/test_tui_launch.py
) Review follow-up. _show_validate wrapped its JSON leg but not the degrade's run_captured, and run_captured *calls* run_captured_streams -- so the two legs spawn the same subprocess. A failure to spawn at all (a venv deleted out from under sys.executable, EAGAIN off a loaded process table) is therefore not a JSON-leg failure the text re-run recovers from; it is the same failure twice, and the second one escaped into @work(thread=True)'s exit_on_error=True and took the whole app down -- the exact outcome the first guard exists to prevent. test_validate_worker_survives_a_raising_subprocess did not catch it because it stubbed run_captured_streams to raise while stubbing run_captured to succeed, a split production cannot produce. Stubbing only the streams seam models the real thing, and the raise stands in for the spawn so no subprocess runs either. _show_captured had the same unguarded call, and unlike the validate one that crash ships today: both dry runs die the same way. Rather than a second copy of the try/except, the guard is extracted to _run_captured_guarded, now the only caller of launch.run_captured in src/ -- a chokepoint instead of two blocks that can drift. The reason lands in the modal body rather than a notify() because the modal is already opening, and a blank panel under an "exit 1" header would say only that something went wrong; the header names the command, so the body does not repeat it. Only the dry-run crash gets a CHANGELOG entry. The validate one existed solely on this unreleased branch, so there is nothing shipped to report.
…#212) documents.py is the library-level read-model projection layer, and its docstring says outright that a non-CLI frontend (the planned web backend) imports these builders directly. Every builder nonetheless kept the leading underscore it had as a private function inside cli.py, which says the opposite. The six *_SCHEMA_VERSION constants beside them were already public; the split had no rationale, it was an artifact of the origin. Deferred out of bmad-code-org#211 because that PR's deliverable was a provable verbatim relocation (AST-identical bodies, untouched tests as the proof) — renaming in the same commit would have voided both. run_token_totals goes public too. The issue framed it as an open question, on the grounds that it is a shared helper rather than document API and could stay a private sibling. It cannot: cmd_status imports it across the module boundary and calls it on the text path, and a private name imported by another module is the exact contradiction being removed. The run_ prefix stays — it marks run-level vs per-task aggregation, which is what the function exists to get right. No back-compat cli._x_document aliases: nothing in-tree needs them and they would re-create the private surface this removes (same call the Unreleased notes already record for runs.tmux_sessions -> mux_sessions). No __all__: no leaf module in the package has one, including probe.py and diagnostics.py, which this module is modelled on. No new CHANGELOG entry — nothing user-visible changes — but the Unreleased entry naming _run_token_totals is updated, since it is unshipped notes rather than history. Also adds the library path's first coverage. Every other --json test reaches the builders through cli.main, so nothing pinned the docstring's central promise that the two paths agree. Two parity tests drive one fixture down both and assert the same dict comes back. Equality is against the raw builder return, not a json round-trip: a consumer holds this dict before serializing, so a tuple where a list belongs is a real defect a round-trip would hide — verified by injecting exactly that and watching the test fail. conftest.make_validate_document (new in bmad-code-org#215) was already calling the builder directly, so it needed the rename too. Note make_validate_document contains _validate_document as a substring — the rename is word-boundary anchored so it and its 13 call sites are untouched.
Closes #210.
Pressing
vin the TUI ranbmad-loop validatein text mode and dropped the capturedblob into
TextOutputModal, with the verdict inferred from the exit code. That meant severitywas a string prefix, the verdict conflated "the checks failed" with "the command broke", and
every check's
detail— the data it knew before flattening itself into a sentence — wasalready gone by the time the TUI saw it. This moves the modal onto the schema-v1 document from
#205 and renders it structurally.
Three commits, each self-contained:
609a56755bdd43mux.external-backendtowarningPhase 1 — seams
launch.run_captured_streams(argv_tail) -> (rc, stdout, stderr).run_capturedmerges thechild's stderr onto stdout after it, so
json.loadson the merged string raisesExtra data:— oneDeprecationWarningfrom any dependency would have silently degradedthe feature to the text modal on some machines and not others.
run_capturednow delegatesto the new function and merges, so there is still exactly one
subprocess.run.encoding="utf-8", errors="replace"rather thantext=True:text=Truedecodes with thelocale encoding at
errors='strict', which is the diagnose --json can raise UnicodeEncodeError on a non-UTF-8 console #200 family of failure the JSON pathalready fixed CLI-side.
widgets.validate_document()(parse + version pin + shape gate, returnsNonerather thanraising),
validate_findings()(oneTable.grid, so columns align across findings) andvalidate_header().widgets._RENDERS_VALIDATE_SCHEMA = 1is a hand-written literal, deliberately not animport of
documents.VALIDATE_SCHEMA_VERSION— an import would auto-follow a CLI bump andsilently render a v2 document as v1. A tripwire test asserts the two are equal, since
otherwise a mismatch would first surface as a silent degrade in a user's terminal with CI
green.
Phase 2 — modal and wiring
ValidateFindingsModal+ a new_show_validateworker._show_capturedis untouched — therun --dry-runandsweep --dry-runmodals still use it.doc["ok"], never rc.warning/problem,dtoggles it for everything. One severity rule, zerocheck-id matching (
messageis not contracted;checkis). Rendering all detail inlinewould make a passing modal ~3.6× longer than today's while restating messages already on
screen.
json.dumpsleaf fallback, not recursive:detailis notflat — the passing path emits
{"gates_mode": str, "adapters": {"dev": ...}}as the secondfinding of every successful validate, and a scalar-only renderer prints a Python repr. The
bridge test drives a live
validate --jsonand asserts"{'" not in rendered, so itcannot rot against a fixture.
problemexists the header appends a dim footer noting the gates are chained. Thatputs
documents.py's "absence is not a pass" on screen, and is the one thing the structuralrenderer can teach that the text cannot: a short findings list is not a short list of
problems.
try/except.@work(thread=True)defaults toexit_on_error=True(Textual 8.2.7), so an unguardedJSONDecodeError/KeyErrorin the newparse-and-render body would take down the whole app. Not
exit_on_error=False— that turns acrash into pressing
vand nothing happening.non-empty but unrenderable, showing the merged blob means a wall of
{"schema_version": 2, …}in a modal titled
validate — exit 1, withholding a perfectly good human rendering at theexact moment the structural one failed. Re-running costs one sub-second subprocess on a path
that should never fire, and makes the claim trivially reviewable: the degrade is
byte-for-byte the pre-TUI: validate modal should consume validate --json and render findings structurally #210 behavior.
Transport is subprocess +
--json, which is a knowing exception todocuments.py's "a non-CLIfrontend should import the builders directly and never parse CLI stdout" — the TUI is such a
frontend. The justification is in the worker's docstring:
cmd_validateloads third-party muxentry points and probes
httpx, so the subprocess quarantines a broken plugin's import sideeffects and leaves the TUI's own
lru_cached mux selection undisturbed. Extracting anin-process
build_validation_report()is worth filing as a follow-up.Phase 3 —
mux.external-backend→warningThe finding has always read as a failure — "external mux backend 'x' failed to load: …" — while
carrying severity
ok, so it counted as a passing check. It was pinned there only becausepromoting it inserts
warning:into shipped text the TUI rendered verbatim; the TUI no longerdoes.
bmad-loop muxhas always printed this same condition aswarning:, so validate was theoutlier. It stays below
problemdeliberately: selection degrades past a broken external (afailed external can never be the selected backend), so the verdict and rc must not flip. The
comment at the site argued the finding was advisory; it now says that.
Two things worth stating rather than leaving implicit:
1. No schema bump — a decision, not an oversight. On an affected host the promotion flips
counts(warning+1,ok−1) but leavesokand rc alone.machine.pysays changing "themeaning of a value" bumps, and someone could reasonably read a changed count that way. The
counter-argument is that
documents.pycontracts thecheckid as the matchable identity,not any given check's outcome — a consumer branching on
mux.external-backendstill finds it,and one reading
countsis reading a tally of severities that was always free to move aschecks changed their minds. Under a bump-on-any-count-change reading, every future severity
adjustment would be a breaking change, which is not what the additive-only rule is protecting.
Happy to bump if you disagree — it is cheap now and expensive later.
2. The CLI text still changes, and gets uglier. That line becomes:
because
render()preserves the double prefix by design (the warning sites stored" warning: " + msginto the same list theok:printer walked). #210 unblocks the TUIhalf of the freeze, not the CLI half.
test_validation_report_renders_each_severity_verbatimis the byte-exact lock and stays green — it builds its own findings and never touches this
check — and the new test asserts the now-shipping line at the real site, so the consequence is
pinned rather than incidental.
Two UI notes
Footershows through beneath every modal, so while this one is open thefooter still reads
d decisions. That is pre-existing for all modals, not new here; thein-modal
d — toggle detail on every findinghint covers discoverability, and the modal'sbinding wins since it holds focus.
by dim styling. Legible in a real terminal, but if you want a visual marker that is a
Phase-1 renderer change, so I left it alone.
Testing
Full suite green, full
trunk check(no filter) clean.Nothing asserted
mux.external-backend's severity before this —test_external_backends.pyonly covers
external_backend_errors()contents — so Phase 3 adds a test pinning the severity,the detail, and the rendered text.
The degrade path was verified live during Phase 2 — forcing
validate_documenttoNonereproduces the old modal exactly. The rest is covered by the automated cases: the four degrade
shapes, the
dtoggle, a raising subprocess (theexit_on_errortrap), and both verdicts withrc deliberately disagreeing with what the old code would have inferred from it. Worth a look at
96 columns by a second pair of eyes before merge.
Docs:
docs/tui-guide.mddescribed the replaced behavior verbatim in two places.Summary by CodeRabbit
New Features
validate --json, including verdict-derived summaries, severity counts, check IDs/messages, and an expandable details toggle.v) and its on-screen guidance were refreshed to match the new structured renderer.Bug Fixes