fix(cargo): surface --message-format=json build errors instead of "compiled"#2422
Conversation
3a207bc to
b5aff4a
Compare
…mpiled" With --message-format=json, cargo emits diagnostics as NDJSON, so the block handler that keys on human `error[..]` lines counted zero errors and summarized a failing build as "cargo build (1 crates compiled)" — the exact same wording as a successful build. The error was dropped from the inline output and only the exit code (easily swallowed by an && / || chain) hinted at failure. Recover the NDJSON `compiler-message` diagnostics from the raw stream when building the summary: count them and render them. A failed json build now shows the real error[E..] plus "N errors", and the exit code is used as a backstop so a non-zero build is never reported as compiled. Non-json output is untouched (the helper returns nothing for it). Closes rtk-ai#2419
b5aff4a to
99307b2
Compare
Parse the --message-format=json stream once into errors and warnings instead of scanning it twice per call site, and move the success and failure summaries into two small helpers shared by the streaming and batch paths. Also render json warnings, not just errors, so json mode matches how the human path already surfaces warning blocks.
hgunduzoglu
left a comment
There was a problem hiding this comment.
Checked this out and tested it locally (macOS arm64) against a real failing build — works exactly as described, and the success/non-json paths are untouched:
| Command | before | this PR |
|---|---|---|
rtk cargo build --message-format=json (failing) |
cargo build (1 crates compiled), exit 101 |
error[E0308]… + cargo build: 1 errors, 0 warnings, exit 101 ✓ |
rtk cargo build --message-format=json (passing) |
1 crates compiled |
1 crates compiled ✓ (no regression) |
rtk cargo build (non-json, passing) |
1 crates compiled |
1 crates compiled ✓ |
cargo fmt/clippy/test clean, 51 cargo_cmd tests pass. The refactor into cargo_build_success_line / cargo_build_failure_summary is a nice dedup of the streaming and batch paths, and using the exit code as a backstop in format_summary is the right call — it means a non-zero build can never be summarized as compiled even if the diagnostic shape changes.
One non-blocking observation (token-savings angle): in cargo_build_failure_summary, the JSON diagnostics are emitted uncapped (for d in &json.errors { … }), whereas the human-text path in filter_cargo_build caps blocks at MAX_CHECK_BLOCKS = CAP_ERRORS. A --message-format=json build with many errors would therefore print every rendered diagnostic in full — which can get large, and is exactly what RTK normally bounds. Might be worth applying the same CAP_ERRORS cap (with a … N more style hint) to the JSON error list for consistency. Not a blocker — the correctness fix here is the important part.
Nice work, and good test coverage on both the streaming and batch paths.
The --message-format=json failure summary rendered every error and warning in full, while the human-text path bounds blocks at CAP_ERRORS. A json build with many errors could therefore print an unbounded wall of diagnostics — exactly what RTK normally caps. Cap the json error/warning lists at CAP_ERRORS/CAP_WARNINGS and append a '… +N more' hint on overflow. The summary line still reports the real total counts.
|
Thanks a lot @hgunduzoglu for taking the time to test this end to end. And good catch on the uncapped JSON diagnostics. You're right that it should match the human-text path, so I've capped the JSON errors at CAP_ERRORS (and warnings at CAP_WARNINGS) with a … +N more hint when it overflows. The summary line still reports the real totals, so nothing gets lost. Pushing it now. |
|
@hgunduzoglu quick follow-up on your point about capping the JSON diagnostics. While wiring that cap in, I went digging and found something bigger. The human-text path isn't actually capped on the real command path either. I reproduced it on a 25-error crate: plain I'd like to fix it in a follow-up: wire the cap into Okay if I pick this up? |
cargo_cmd had no token-savings test despite the repo's >=60% rule, so nothing locked in the gain from extracting rendered diagnostics out of the verbose json envelope and capping them. Add a savings assertion on a large failing --message-format=json build.
|
Also added a token-savings test on the json failure path while I was in there, since That wraps up the json side for me. The streaming cap is the separate follow-up I described above. |
The json-aware summary had tests for the failure and capped paths but none for a clean --message-format=json build, where artifacts and build-finished:true must still report crates compiled.
hgunduzoglu
left a comment
There was a problem hiding this comment.
Re-reviewed the updated branch (through d061c23) — this fully addresses my cap point. Pulled it and tested on a 25-error crate:
$ rtk cargo build --message-format=json # 25 type errors
… (20 rendered diagnostics)
… +5 more errors
cargo build: 25 errors, 0 warnings (1 crates) # exit 101JSON errors cap at CAP_ERRORS, warnings at CAP_WARNINGS, each with a … +N more hint, and the summary still reports the true totals — exactly matching the human/batch path. cargo fmt --check, clippy --all-targets, and cargo test all clean (54 cargo_cmd tests, including the new token-savings assertion on the failure path and the success-path test — nice touch locking the ~74% reduction in so the cap can't silently regress). LGTM from my side.
On your follow-up — yes, please do, and as a separate PR. I reproduced exactly what you describe on the default path:
$ rtk cargo build # no --message-format=json, 25 errors
# prints all 25 full E0308 blocks, no "+N more" hintConfirmed blocks_emitted in core/stream.rs is declared, initialized, and incremented (self.blocks_emitted += 1) but never read — so the streaming cap was clearly intended and left unwired. Reusing it to bound build/check/test at CAP_ERRORS with the same … +N more hint (full output still in the tee log) is the right fix, and the default path is the higher-impact one since it's the common case. Keeping it out of this PR is the correct call — this one stays focused on the json regression. Happy to review that follow-up when it's up.
There was a problem hiding this comment.
Code Review — AI-assisted (Claude Sonnet 4.6 via Claude Code)
This review was generated using multi-agent AI analysis (8 finder angles × parallel verification). Findings are ranked by severity; each one survived a dedicated verify pass.
Overview
The fix is sound and addresses a real, impactful bug: CargoBuildHandler only matched human-readable error[/warning: prefixes, so --message-format=json builds always reported "compiled" regardless of outcome. The approach of parsing compiler-message NDJSON records at exit via extract_json_diagnostics() and merging into both paths is clean. The six new tests cover the core scenarios well.
Four issues surfaced that a maintainer should address before merge.
🔴 Finding 1 — cargo_build_failure_summary omits force_tee_hint (data loss)
When json.errors.len() > CAP_ERRORS, the overflow line "… +N more errors\n" is emitted but the dropped errors are silently discarded — no tee file is written and no recovery hint is shown to the user.
Every other overflow path in the file calls force_tee_hint:
// filter_cargo_build human path (line ~819) — DOES call it:
if let Some(hint) = crate::core::tee::force_tee_hint(&all_blocks, "cargo-check-issues") {
result.push_str(&format!(" {}\n", hint));
}
// new cargo_build_failure_summary JSON path — MISSING:
if json.errors.len() > CAP_ERRORS {
out.push_str(&format!("… +{} more errors\n", json.errors.len() - CAP_ERRORS));
// ← no force_tee_hint here
}force_tee_hint writes the full content to ~/.local/share/rtk/tee/ and returns a [full output: ~/path/to/file.log] hint so the user can retrieve the rest. Without it, errors beyond the cap are permanently lost for that session.
Fix: pass the overflowed diagnostics to force_tee_hint using a label like "cargo-json-errors", mirroring the human path.
🟡 Finding 2 — strip_ansi not called on rendered field
extract_json_diagnostics pushes msg["rendered"] verbatim into the output bucket:
bucket.push(rendered.trim_end().to_string());The rendered field contains ANSI escape sequences when CARGO_TERM_COLOR=always is set (common in CI) or when --message-format=json-diagnostic-rendered-ansi is used. strip_ansi exists at src/core/utils.rs:48 and is called by every other filter module that handles compiler output.
Fix:
bucket.push(crate::core::utils::strip_ansi(rendered).trim_end().to_string());🟡 Finding 3 — "N warnings generated" summary record not filtered
In JSON mode rustc emits a compiler-message record whose message field is "N warning(s) generated" alongside the actual warning diagnostics. extract_json_diagnostics only skips "aborting due to" and "could not compile" — not the "generated" summary. Result: json.warnings.len() is inflated by 1, the count in the summary line is wrong, and the noise text appears in the rendered output.
Human mode handles this correctly in should_skip:
if line.starts_with("warning:") && line.contains("generated") && line.contains("warning") {
return true;
}Fix: add a parallel guard in extract_json_diagnostics:
if text.starts_with("aborting due to")
|| text.starts_with("could not compile")
|| (text.contains("warning") && text.contains("generated"))
{
continue;
}🟡 Finding 4 — No explicit arg-based routing for JSON mode (altitude)
The fix detects JSON mode opportunistically by examining output content rather than inspecting the --message-format flag at call time. This means:
- All three cargo JSON variants (
json,json-diagnostic-rendered-ansi,json-render-diagnostics) are handled identically with no place to branch on variant-specific behavior (e.g. ANSI stripping for the-rendered-ansivariant). - The
.max(handler.error_count, json.errors.len())guard is the only thing preventing double-counting if both human and JSON diagnostics appear — safe today by coincidence, not by construction. - Two callers (
CargoTestHandler::format_summaryline 188,filter_cargo_nextestline 1041) checkbuild_filtered.starts_with("cargo build:")before accepting the result. After this PR,cargo_build_failure_summaryputs rendered diagnostics before the summary line, so this check silently falls to the "last 5 meaningful lines" fallback iffilter_cargo_buildever receives JSON output. Currently guarded byhas_compile_errorsstaying false in JSON mode — but that's a hidden invariant, not an enforced one.
PR #2750 (open, same issue) solved this with a focused has_json_message_format(args) that explicitly handles all three variants and their space-separated forms. Borrowing that function as a guard at the run_build/run_check level would:
- make JSON mode an explicit routing decision
- close the
starts_withfragility above - give a natural place to apply
strip_ansiunconditionally on the JSON path - replace the
.max()defence-in-depth with mutual exclusion by construction
🟡 Finding 5 — starts_with("cargo build:") invariant is silently fragile
After this PR, cargo_build_failure_summary builds its output by placing rendered diagnostic text before the "cargo build: N errors..." summary line. Two callers rely on that prefix to accept the result:
// CargoTestHandler::format_summary, line ~188:
let build_filtered = filter_cargo_build(raw);
if build_filtered.starts_with("cargo build:") { // ← fails if json.errors non-empty
return Some(format!("{}\n", build_filtered.replacen("cargo build:", "cargo test:", 1)));
}
// silent fallback: last 5 meaningful lines (raw NDJSON in JSON mode)The same guard appears in filter_cargo_nextest (~line 1041).
Today this is safe: has_compile_errors is only set when a human-readable error[ or error: prefix is detected, which never fires on {-starting NDJSON lines, so filter_cargo_build is never called with JSON output. But that safety is a hidden
invariant — the moment someone extends has_compile_errors to cover JSON errors (a natural next step), both callers silently fall back to dumping raw NDJSON to the LLM instead of a clean "cargo test: N errors" summary.
This is one of the two issues that explicit arg-based routing (Finding 4) would close by construction: with has_json_message_format(args) as a guard, the JSON and human paths are mutually exclusive at the routing level, so filter_cargo_build can never receive JSON output regardless of how has_compile_errors evolves.
⚪ Cleanup
Duplicate counting setup — the same 3-line block is copy-pasted into both CargoBuildHandler::format_summary and filter_cargo_build. When the .max() logic changes (e.g. to fix Finding 3), there are two places to update.
Triple allocation in cargo_build_success_line — three chained format! calls each allocate and discard the previous string. A single format! with a conditional covers both cases.
Review performed via Claude Code (claude-code, Sonnet 4.6) using 8 parallel finder angles + dedicated verifier per finding. Findings #1–3 are directly actionable; Finding #4 is the architectural note from comparing this PR with #2750.
|
Thanks for the thorough pass @KuSh, really helpful. Went through each one, all pushed on this branch: Finding 1 (force_tee_hint): fixed ( Finding 2 (strip_ansi): fixed ( Finding 3 ("N warnings generated"): fixed ( Cleanup: done ( Findings 4 and 5 (arg-based routing / @hgunduzoglu thanks for the two rounds of hands-on testing. The default (non-json) path cap you confirmed via the unwired |
…detection Detect --message-format=json (and the json-* variants, inline and space-separated) in the args and route build/check to the batch filter_cargo_build path instead of inferring json mode from output content. Makes json routing an explicit decision and removes reliance on the has_compile_errors invariant.
The test set a shared RTK_TEE_DIR and read back the tee file, but with the finding-1 tee call other parallel tests now write to the same dir and the max_files cleanup could evict the file before the read. The overflow line and true totals are already covered by test_filter_cargo_build_json_errors_capped, and force_tee_hint has its own tests in core/tee.rs.
contains("generated") could swallow a real diagnostic mentioning both
"warning" and "generated"; the rustc summary record ends with "generated",
so match that precisely.
|
@KuSh Two out-of-scope items I hit while wiring the json routing, both pre-existing (not introduced here), flagging so they don't get lost:
Both sit outside this regression fix. Happy to open a follow-up for the test one if you agree. |
It would be great to extend this PR to handle cargo json format messages globally. |
The !is_empty() guard was always true (filter_cargo_build_labeled never returns empty), making the last-5-lines fallback dead code and letting a success-shaped line through on a false-positive compile-error detection. Check for the "cargo test:" failure summary instead.
cargo install --message-format=json is effectively never used, and routing its success through the build filter dropped the "Installed <crate>" line (cargo prints it as plain text, not a json record). Keep install on its dedicated filter which preserves that information.
cargo uses last-flag-wins, so --message-format=json --message-format=human builds in human format. Track the last occurrence instead of returning on the first json match.
The batch filter built the handler with a hardcoded "build" label. It is unused today (only the free summary functions receive the label) but would silently mislabel if the function ever called handler.format_summary. Pass the real label through.
cargo emits "\`crate\` generated N warnings" which ends in "warning", so the
ends_with("generated") check never matched it and the summary could inflate
the count. Use contains, matching the human-text skip elsewhere in the file.
Routing clippy json through the build filter reported a clean run as "cargo clippy (N crates compiled)". Detect the clean case up front and emit the same "cargo clippy: No issues found" the human path uses; errors and warnings still route through the diagnostics summary.
|
Thanks @KuSh. I've extended the PR so cargo json output is handled consistently:
|
| self.error_count, self.warnings, self.compiled | ||
| )) | ||
| fn format_summary(&self, exit_code: i32, raw: &str) -> Option<String> { | ||
| let json = extract_json_diagnostics(raw); |
There was a problem hiding this comment.
This seems unnecessary now that you check has_json_message_format, did I miss something?
There was a problem hiding this comment.
Hey @KuSh,
I realized I didn't check everything carefully. I was trying to finish it all while having lunch today. Let me check everything again.
| filter_cargo_build_labeled(output, "build") | ||
| } | ||
|
|
||
| fn filter_cargo_build_labeled(output: &str, label: &'static str) -> String { |
There was a problem hiding this comment.
This function, unlike run_cargo_streamed and run_build/run_check, doesn’t handle non-zero exit codes, which seems wrong
| } | ||
| } | ||
|
|
||
| fn cargo_build_failure_summary( |
There was a problem hiding this comment.
it should take exit code and special case 0 errors, 0 warnings if exit != 0
| } | ||
| let msg = &v["message"]; | ||
| let bucket = match msg["level"].as_str() { | ||
| Some("error") => &mut errors, |
There was a problem hiding this comment.
it seems to be missing Ice and also perhaps FailureNote, see https://docs.rs/cargo_metadata/latest/cargo_metadata/diagnostic/enum.DiagnosticLevel.html#variants
It could also be missing from the text filtering path and be handled later
| } | ||
|
|
||
| fn run_test(args: &[String], verbose: u8) -> Result<i32> { | ||
| run_cargo_streamed( |
There was a problem hiding this comment.
run_test doesn't handle json message formats
There was a problem hiding this comment.
This one is handled by the streamed CargoTestHandler rather than a routing branch: --message-format=json only reformats the build phase, the test harness output stays human, so the handler aggregates the "test result:" lines and parses the json diagnostics in format_summary on a compile error (covered by test_cargo_test_stream_json_compile_error). Routing it through the batch path would change the failing-test output format, so I added a comment on run_test explaining the choice in 1143e6f. Happy to switch to an explicit branch if you'd rather.
| out.push_str(&format!( | ||
| "cargo {}: {} errors, {} warnings ({} crates)\n", | ||
| label, errors, warnings, compiled | ||
| )); |
There was a problem hiding this comment.
This line was always top before your change, this should stay that way
| for d in json.errors.iter().take(CAP_ERRORS) { | ||
| out.push_str(d); | ||
| out.push('\n'); | ||
| } | ||
| if json.errors.len() > CAP_ERRORS { | ||
| out.push_str(&format!("… +{} more errors\n", json.errors.len() - CAP_ERRORS)); | ||
| } |
There was a problem hiding this comment.
Use join_with_overflow from utils
| warnings: Vec<String>, | ||
| } | ||
|
|
||
| fn extract_json_diagnostics(raw: &str) -> JsonDiagnostics { |
There was a problem hiding this comment.
Please take inspiration from filter_go_test_json for that function
| fn new() -> Self { | ||
| Self::with_label("build") | ||
| } | ||
|
|
||
| fn for_check() -> Self { | ||
| Self::with_label("check") | ||
| } | ||
|
|
There was a problem hiding this comment.
As there's only two call site for those wrappers, I'd recommend dropping them and directly call with_label("build")/with_label("check") there
Deserialize each compiler-message line into typed structs instead of indexing serde_json::Value, mirroring filter_go_test_json. Route the internal-compiler-error level into the errors bucket so an ICE is no longer dropped.
|
Thanks @KuSh, all addressed. The streamed handler no longer parses json (it only sees non-json), the batch filter takes the exit code now so a failed build with nothing to parse says failed instead of compiled, and an ICE counts as an error. Also switched to typed structs like filter_go_test_json, put the summary line back on top, used join_with_overflow for the caps, and dropped the new()/for_check() wrappers. Replied on the run_test thread separately. |
pszymkowiak
left a comment
There was a problem hiding this comment.
Reviewed and verified locally: cargo fmt clean, cargo clippy --all-targets zero warnings, full test suite green (2177 passed / 0 failed).
Confirmed the bug independently on current develop HEAD before applying this patch: rtk cargo build --message-format=json on a broken crate prints cargo build (1 crates compiled) — reads as success — while the real exit code is 101. An agent skimming the output (rather than checking the exit code) would wrongly conclude the build succeeded. This fix surfaces the actual error[E0308]... diagnostic and the correct cargo build: N errors, M warnings summary.
Verified end-to-end with a real broken crate (let x: i32 = "nope";):
- Before (unpatched):
cargo build (1 crates compiled), exit 101 — misleading. - After (this PR):
error[E0308]: mismatched types+cargo build: 1 errors, 0 warnings (1 crates), exit 101 — correct. - Passing build unaffected:
cargo build (1 crates compiled)+Finished ..., exit 0 — no regression.
Also like the exit_code backstop in cargo_build_failure_summary (cargo build: failed (exit N) when no JSON diagnostics parse, e.g. a build-script failure) — closes the bug class, not just the compiler-message case. Good test coverage (20+ new tests) including that backstop (test_batch_filter_nonzero_exit_without_diagnostics_is_not_compiled).
LGTM 👍
Summary
Fixes #2419.
rtk cargo build --message-format=json(andcargo check --message-format=json) reported a failed build ascargo build (1 crates compiled)and dropped the compiler error — the filtered output was identical to a successful build except for the (easily-swallowed) exit code, and "compiled" reads as success.Root cause
The build summary is produced by a line handler that keys on the human
error[..]/warning:text. With--message-format=json, cargo emits those diagnostics as NDJSON ({"reason":"compiler-message","message":{"level":"error",...}}), which the handler never matches, so it counted zero errors and fell into the "compiled" branch.Fix
extract_json_diagnostics()recovers the NDJSONcompiler-messagerecords from the raw stream and returns theirrenderedtext for a given level. Both summary paths (the streamingCargoBuildHandler::format_summaryand the batchfilter_cargo_build) now fold those into the error/warning counts and print the rendered diagnostics. The streaming path also uses the exit code as a backstop, so a non-zero build is never summarized as compiled. Non-json output is unaffected — the helper returns nothing for it (mirrors the existingaborting due to/could not compileskips).Before / After
A passing json build is unchanged (
cargo build (1 crates compiled)+Finished, exit 0).Tests
test_cargo_build_stream_json_failure_not_compiled(streaming) andtest_filter_cargo_build_json_failure_not_compiled(batch): feed NDJSON error records and assert the error is surfaced, the count is right, and the output is not reported as "compiled".cargo fmt --all && cargo clippy --all-targets && cargo test— clippy clean, all cargo tests pass, no regressions.error[E0308]+ exit 101; passing build → "compiled" + exit 0).