Repository navigation
Conversation
HetCreep
left a comment
There was a problem hiding this comment.
Ran the branch rather than only reading it — build at c15ccb5, cargo test = 2613 tests, all pass, which matches the number in the description.
Then probed guard_exit against the summary strings the nine touched filters actually emit (each pulled from source rather than invented). 19 of 22 behave as intended, including both negative controls. Three do not, and they are all the same branch.
1. pytest's failed == 0 + extras branch bypasses the guard
filter_pytest_output returns the short green form only when there are no extras:
// pytest_cmd.rs:202-204
if failed == 0 && passed > 0 && !extras_present {
return format!("Pytest: {} passed", passed);
}With any skipped / xfailed / xpassed test it falls through to :207 and renders Pytest: 44 passed, 0 failed, 3 skipped. That string hits this line in looks_green:
if t.contains("failed") || t.contains("errors") { return false; }which was written for the vitest shape, where the word is fail (, not failed — so looks_green's own fail (0) allowance doesn't apply, and the literal 0 failed is read as evidence of failure and switches the guard off.
End-to-end through the exact closure at pytest_cmd.rs:57-67, same fixture, only difference is three skipped tests:
| child exit | run | rtk renders | guard |
|---|---|---|---|
| 1 | 44 passed, 3 skipped | Pytest: 44 passed, 0 failed, 3 skipped |
not fired |
| 1 | 47 passed, no extras | pytest: failed (exit 1) + raw tail |
fired |
| 1 | 45 passed, 2 xfailed | Pytest: 45 passed, 0 failed, 2 xfailed |
not fired |
| 0 | 44 passed, 3 skipped | Pytest: 44 passed, 0 failed, 3 skipped |
not fired (correct) |
Row 2 is the control: identical failure, identical exit code, and the guard works — the only thing that disables it is the presence of skipped tests. The direction is the awkward part: the more detailed the green summary, the less protected it is, and skipped tests are close to universal.
Minimal fix, reusing the digit-boundary idiom this PR already introduces in lint_cmd.rs (needed for the same reason — 10 failed also ends in a zero):
static ZERO_FAILED: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:^|[^0-9])0 failed").unwrap());
// in looks_green
if t.contains("passed") || t.contains("pass (") {
if t.contains("errors") { return false; }
if t.contains("failed") { return ZERO_FAILED.is_match(&t); }
return !t.contains("fail (") || t.contains("fail (0)");
}Suggestion only — I ran the probe, not this patch.
2. looks_green is an allowlist, so the guard is default-allow
The stated principle is that an all-green summary must never render when the child exited non-zero, but the implementation only acts when the summary matches a known green phrase. A filter added later, or an existing summary reworded, silently opts out — and no test fails when it does. The pytest gap above is the first instance of that, not a one-off.
A looks_failed() denylist (fire unless the filtered text already communicates failure) would be default-deny and match the stated principle. Bigger blast radius though — passthrough output, where filtered is essentially raw, would need an exemption — so it's your call. At minimum, a comment on looks_green noting the list has to be extended whenever a filter's green string changes would stop the next one being silent.
3. "across all tool filters" is nine of them; gradlew has the same bug class
Not asking for it in this PR — flagging so it isn't assumed covered.
src/cmds/jvm/gradlew_cmd.rs at d977e1c3 contains no reference to an exit code anywhere outside test names. filter_connected ends:
// gradlew_cmd.rs:350-354
let filtered = filter_test(&joined);
if filtered.trim().is_empty() {
return "ok ✓ (connected tests passed)".to_string();
}INSTRUMENTATION_RESULT: lines are stripped at :335 — so a connected-test run whose only failure evidence is INSTRUMENTATION_RESULT: shortMsg=Process crashed filters to empty and is rendered as an explicit pass. All four gradlew filters go through runner::run_filtered (:142-168), which never sees the exit code; the run_filtered_with_exit pattern this PR uses for pytest and go drops in unchanged.
Checked and found correct
Listing these so they don't get re-litigated:
- The
ZERO_ERRORSfix inlint_cmd.rs:466is a genuine second bug, well caught.line_lower.contains("0 error")was matching10 errorsand20 errors, so those lines were never counted. The added test pins exactly the right boundary case. - vitest is safe. It renders
PASS (n) FAIL (m), which thefail (0)clause handles, including withskipped (n)appended. - go is safe.
go_cmd.rs:419-428emits the passed-count line only when!has_failures, and appendsskippedonly inside the failure branch — so0 failednever appears in a green go summary. - cargo test and nextest are safe. The all-pass branch at
cargo_cmd.rs:778-792never writes the wordfailed. - dotnet is not a gap. I suspected it first and was wrong:
compose_failure_outputalready prepends raw stdout/stderr whenever the command failed.
Not checked
No real pytest / gradle / go toolchain was run — the probes drive the filters and guard_exit directly, using strings this repo emits, each cited to file:line. The motivating trigger I used for a non-zero exit with no test failure is pytest-cov --cov-fail-under, which I did not run; the finding doesn't depend on it, since any non-zero exit with failed == 0 and extras present reproduces it. I also haven't checked the remaining non-JVM filters (sbt, prisma, pnpm, …) for the same class.
Analysis done with Claude (Anthropic); every claim above came from building this branch and running it, not from reading alone. The suggested patch is untested.
|
@HetCreep Thanks for the thorough review! All 3 issues fixed:
All tests still passing. Ready for re-review! |
HetCreep
left a comment
There was a problem hiding this comment.
Built this and ran it rather than reading the diff — here's what I found.
The 3 original findings: fixed, and well
pytest / 0 failed with skips — guard.rs doesn't patch around this, it inverts the whole mechanism: looks_green is gone, replaced by looks_failed(), a denylist. guard_exit now fires unless the text already reads as a failure, which is the structural fix I was hoping for rather than a one-off regex. zero_failed_counts_are_not_failures and guard_detects_zero_failed_with_skips_as_green pin the exact case from my first review.
The allowlist → default-allow architecture — addressed directly, not worked around. guard_catches_reworded_green_summaries_by_default tests the denylist against five synthetic strings ("All good — 5 suites green", "Everything passed cleanly", …) that aren't tied to any real filter — testing the generality of the fix, not just its coverage of known cases. I ran my own version of that idea independently, with four strings of my own (not yours): "Compilation finished with zero remarks", "Everything checks out, 0 flags raised", "Build summary: nothing to report", "suite finished, 12 ok 0 not-ok". Two of mine deliberately contain a bare 0 to probe whether the digit-boundary regexes overreach — they don't. All four fired correctly, 0 misses.
gradlew — all four task arms (Test, ConnectedTest, Lint, Dependencies) now go through run_filtered_with_exit into guard_exit. test_connected_crash_guard_replaces_green_verdict uses the exact fixture I put in the first review (INSTRUMENTATION_RESULT: shortMsg=Process crashed) and confirms filter_connected still renders the green "ok ✓ (connected tests passed)" on its own — but the guard now catches it and replaces it with the failure verdict. That's the right fix: not chasing the filter's own bug, just making sure nothing downstream can render it as success.
Two build-breaking bugs on the current head, unrelated to the above
The crate does not compile at 58cee0d. Both look like fallout from merging develop into this branch, not from the PR's own intent.
1. go_cmd.rs:274
let CaptureResult { stdout, stderr, exit_code } =
exec_capture(&mut cmd).context("Failed to run go tool golangci-lint")?;
...
let exit_code = exit_code_from_output(&output, "go tool golangci-lint");exit_code is already correctly bound three lines up. The second let exit_code = … shadows it, calls a function that exists (utils.rs:196) but isn't imported here, and references output, which isn't in scope — error[E0425] ×2. This line isn't on develop and isn't on your branch before the merge commit (9e32b0f); it only exists on 58cee0d. Deleting it is enough — the CaptureResult destructure already has what the rest of the function needs.
2. cargo_cmd.rs — CargoTestHandler's format_summary
develop picked up an unrelated PR (#3430, d003920) that extracted this handler's logic into a new method, compute_test_summary, and made format_summary delegate to it plus never_worse. On this branch, compute_test_summary exists (additive, no conflict), but format_summary still carries the old body — the same logic duplicated inline, verbatim, not calling the new method. Two consequences:
compute_test_summarybecomes unreachable from any non-test code path, and this repo denies warnings (Cargo.toml's[lints.rust]), so-D dead-codeturns that into a hard error —error: method 'compute_test_summary' is never used.- Even fixed to compile, the old inline body never applied
never_worse, sotest_cargo_test_summary_uses_raw_when_summary_is_larger(also from #3430) fails: a one-line compile error, once prefixed with thecargo test: N errors…header, is larger than the raw output, and the test expects the raw output back.
What I verified fixes it — never_worse and guard_exit compose, they don't replace each other, since they check different things (token count vs. exit-code truthfulness):
fn format_summary(&self, exit_code: i32, raw: &str) -> Option<String> {
let summary = self.compute_test_summary(raw)?;
let sized = crate::core::guard::never_worse(raw, &summary);
Some(crate::core::guard::guard_exit(raw, exit_code, "cargo test", sized))
}With both of those applied, cargo test --bin rtk is 2638 passed / 0 failed / 8 ignored. I did not push either change — both were reverted after confirming the fix; this is a suggestion, not a PR, same as everything else in this review.
Why this is worth flagging even though it's "just" a bad merge
Nothing has actually run cargo build on 58cee0d yet — check-runs on this SHA is empty; the only status context is the CLA check. So right now the only verification this commit has had is mine. "All tests still passing" in the PR comment is accurate for the pre-merge commits, but not for what's currently pushed.
Not checked
Same caveats as the first review — no real pytest/gradle/go toolchain, Windows only, controls where runnable. The two build errors and the fix were verified by compiling and running the actual crate, not by reading.
(Analysis done with Claude (Anthropic). Every claim above came from building this branch and running it.)
|
@HetCreep The branch is rebased onto current While rebasing I found that
What this PR now delivers (the delta):
One semantic decision I preserved rather than overrode — flagging for you: Three commits on top of |
58cee0d to
9d19364
Compare
9d19364 to
c13c25b
Compare
|
Rebased onto the current The rebase itself was conflict-free; The CI run on the previous head ended in |
|
I can't do that one, unfortunately — I'm reviewing this PR as an outside contributor, not a maintainer. Approving/re-running a fork PR's Actions workflow needs write access to the repo, and I don't have it (checked: You'll need to flag this for someone with write access — Nothing on the review side changes from my end — I'll take a look at the rebase once someone can get CI running on the new head. |
|
Rebased onto current develop, cargo fmt --check + clippy -D warnings + full suite green locally (3769 passed). Please approve/run the Actions workflow for this head. |
|
Rebased on current develop. cargo fmt --check + clippy -D warnings + full test suite (3769 passed) green locally. @pszymkowiak @KuSh — would you be able to approve/run the Actions workflow for this head? @HetCreep confirmed he can't approve Actions from outside the org. |
KuSh
left a comment
There was a problem hiding this comment.
Round 1 of 3 — CHANGES REQUESTED
Claim: no tool filter may render an all-green summary when the child process exited non-zero; a looks_failed denylist decides, and anything not provably communicating failure is replaced with a standardized <tool>: failed (exit N) verdict.
Scope: accept (round 1, frozen). The invariant is the right one, the denylist-over-allowlist inversion is the right shape for it, and I reproduced it catching two real green lies that develop still tells. Three things have to change first; all three are in code this PR adds.
Ran: real producers, merge-base 6d10430 vs head 234b1eb, one CARGO_TARGET_DIR each (md5 79eb2c8f / 10d80c6c — confirmed distinct before reading any transcript).
pytest 9.1.1 × {collection error (exit 2), ordinary failure (exit 1), all-green (exit 0), no-tests (exit 5), green + xfail with a plugin-forced exit 1} · vitest 3 × {ordinary failures, suite fails to load, all-green} · ruff 0.14 × {check with violations, format --check, config error (exit 2)} · mypy 2.3.1 × {type errors, bad config, fatal usage error (exit 2)} · go × {vet, vet on a build failure, test on a build failure} · cargo test × {pass, fail} · cargo-nextest 0.9.143 × {all-pass with a slow test, failed + slow + skipped}.
Invariants: every branch of the new decision (exit 0 / non-zero-and-looks-failed / non-zero-and-green); split streams (stdout-only filters with everything on stderr — ruff and mypy config errors); under the hook (rtk rewrite "pytest" → rtk pytest, so agents do take this path); forced colour on and off (FORCE_COLOR=3 is set inside Claude Code, and it changes the outcome — see blocker 3).
Savings unchanged from develop on every arm: pytest 83.5%, ruff 21.0%, mypy/go/cargo byte-identical to develop. Both sides clear the 20% floor (CONTRIBUTING.md).
Matrix: never executed before — running now. (Both earlier reviews were thorough but neither ran the producer matrix.)
CI @234b1ebb: all 11 green, on this exact head. Merge into current develop is clean and produces the same 774-line diff, so nothing here is already upstream.
Blocks merge (3, frozen at round 1)
1. src/cmds/js/vitest_cmd.rs:91 — every ordinary failing vitest run is now overcounted, with a fabricated cause.
numFailedTestSuites and testResults[].status == "failed" do not mean "failed to load" — vitest sets both for any file that merely contains a failing test. Real vitest@3, 2 failing tests in 1 of 2 files:
numTotalTests 4 numPassedTests 2 numFailedTests 2 numFailedTestSuites 1 success false
fail.test.js status=failed assertionResults=3
ok.test.js status=passed assertionResults=1
develop: PASS (2) FAIL (2)
1. a fails …
this PR: PASS (2) FAIL (3)
1. [suite] …/src/fail.test.js
test file failed to load (…/src/fail.test.js)
2. a fails …
FAIL (3) against total: 4 with 2 passing, and a first entry that sends the reader looking for an import error that does not exist. The discriminator is whether the file ran any tests — a genuine load failure reports status=failed with assertionResults=0, which I confirmed on a separate run.
2. src/core/guard.rs:80 — a single xfail anywhere in a pytest suite switches the guard off.
has_failed_word's regex is (?:(\d+)\s+)?failed\b with no leading boundary, so it matches the failed inside xfailed with group 1 empty, and is_none_or returns true. FAILURES_WORD (\bfailures?\b, :57) independently matches the failure in pytest's own green Expected-failure outcomes: header, which rtk elicits itself by passing -rxX. Either one alone is enough.
Real pytest, all tests green, one xfail, exit 1 forced by a pytest_sessionfinish gate (the same mechanism as pytest-cov --cov-fail-under):
raw: 2 passed, 1 xfailed in 0.01s exit 1
this PR: Pytest: 2 passed, 0 failed, 1 xfailed ← all-green verdict on a non-zero exit
Control, same gate, same exit code, no xfail: the guard fires correctly. So the xfail is the whole difference, and --cov-fail-under on a suite with one known-broken test is exactly the case #2317 is about.
3. src/core/guard.rs:117 — the standardized failure verdict almost never reaches the user, and under Claude Code's own environment the fallback is worse than what develop printed.
failure_fallback's fixed overhead is ~145 bytes before any content: a 26-char header plus "═" * 39, which is 117 bytes of UTF-8 for zero information. At 8 of the 9 call sites guard_exit's result then goes through never_worse inside run_captured_filter, so whenever the raw output is small — precisely the "green summary, tiny output" case the guard exists for — the whole guarded body loses the token comparison and never_worse discards the verdict, the numbered tail and the tee hint, printing raw instead:
pytest, green + forced exit 1, this PR:
.. [100%]
2 passed in 0.00s
The invariant survives (no green lie), but pytest: failed (exit 1) never appears. With FORCE_COLOR=3 set — which Claude Code sets — the same mechanism turns into a regression on the PR's own headline example, the pytest collection error:
develop: 818 B, ANSI stripped, ends with [full output: …/tee/…_pytest.log]
this PR: 924 B, raw ANSI escapes, no tee hint, no verdict
failure_fallback never calls strip_ansi, and its 117-byte separator is what tips the body over the budget. CargoTestHandler::format_summary is the one site that is genuinely exempt (its comment is accurate there); the other eight are not, which makes the comment misleading as a description of the mechanism.
Two ways out, and this one is your call rather than mine: shrink the fixed overhead (drop the separator, strip ANSI in failure_fallback) so the verdict fits under the budget, or give the guard verdict the same exemption cargo has at the other call sites. The first is smaller; the second is what the doc comment already claims.
Suggested fixes — built and verified, not pushed
Blockers 1 and 2 are two-line changes. I built them on top of 234b1eb and ran the four-arm matrix; cargo test --all stays green (3670 + integration, exit 0). Not pushed — they change user-visible behaviour, which is yours to land, and there is a release in flight.
--- a/src/cmds/js/vitest_cmd.rs
+++ b/src/cmds/js/vitest_cmd.rs
@@
- let failed_suites = if json.num_failed_test_suites > 0 {
+ // Only files that ran NO tests count here: vitest sets status
+ // "failed" and numFailedTestSuites for any file that merely
+ // contains a failing test, and those are already in
+ // num_failed_tests.
+ let unloadable_suites = json
+ .test_results
+ .iter()
+ .filter(|f| f.status == "failed" && f.assertion_results.is_empty())
+ .count();
+ let failed_suites = if unloadable_suites > 0 {
+ unloadable_suites
+ } else if json.num_failed_test_suites > 0 && json.num_failed_tests == 0 {
json.num_failed_test_suites
} else if json.success == Some(false) && failures.is_empty() {
@@ fn extract_failures_from_json
- if file.status == "failed" {
+ if file.status == "failed" && file.assertion_results.is_empty() {
--- a/src/core/guard.rs
+++ b/src/core/guard.rs
-static FAILURES_WORD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bfailures?\b").unwrap());
+static FAILURES_WORD: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"(?:^|[^-\w])failures?\b").unwrap());
@@ fn has_failed_word
- LazyLock::new(|| Regex::new(r"(?:(\d+)\s+)?failed\b").unwrap());
+ LazyLock::new(|| Regex::new(r"(?:(\d+)\s+)?\bfailed\b").unwrap());Before / after, all four arms:
| pytest case | develop | this PR | with the patch |
|---|---|---|---|
green + xfail, exit 1 |
green lie | green lie | guard fires ✓ |
green, no xfail, exit 1 |
green lie | guard fires | guard fires ✓ |
| real failures, exit 1 | summary kept | summary kept | summary kept ✓ |
| all green, exit 0 | Pytest: 1 passed |
same | same ✓ |
| vitest case | develop | this PR | with the patch |
|---|---|---|---|
| ordinary failures | FAIL (2) |
FAIL (3) + false cause |
FAIL (2) ✓ |
| suite fails to load | FAIL (0) ✗ |
FAIL (1) + file named |
FAIL (1) ✓ |
| all green | FAIL (0) |
same | same ✓ |
Optional — will not hold merge
failure_fallbacktakes the first 20 lines (lines.iter().take(…)), while the doc comment says "capped raw tail". For build and test tools the diagnostics are at the end. On a real failingcargo nextest runthe fallback spent its 20 lines onFinished test profile, a box-drawing rule, the nextest run ID andStarting 3 tests…, and cut off before thefailures:section. Tail would be the right end; it also makes the comment true.- The
(N slow)nextest annotation is unrelated to the exit guard and would read better as its own PR. Its regex also puts the group last, afterskipped, while nextest emits it right afterpassed: real output isSummary [ 2.503s] 3 tests run: 2 passed (1 slow), 1 failed, 1 skipped. It parses on the all-green form by group optionality, but1 failed, 1 skippedis dropped. That loss is pre-existing —developdrops it identically — so it is not a blocker, and I have drafted it as its own issue. The two new tests assert301 passed, 0 skipped (0 slow)and50 passed, 3 skipped (2 slow), orderings nextest never emits, so they pass without exercising the real format; worth swapping for a captured line. - The description still lists
tsc — catches position-less global errorsandjest.src/cmds/js/tsc_cmd.rsis not in the diff (developalready covers it, as you noted on 13 Sep) and there is no jest-specific change. Trimming the body to the delta you described in that comment would keep it accurate.
Filed as follow-ups (drafted, not filed — holding until the release is out)
cargo nextestsummary dropsN failed/N skippedwhenever the(N slow)annotation is present — pre-existing ondevelop, no existing issue found (searchednextest,nextest slow,cargo nextest summary skipped, open and closed).- The exit guard covers 9 filters;
rspec,phpunit,mvn,bun,deno,ctest,sbtstill render green summaries without consulting the exit code. Not a regression — the guard is new — but it is the rest of #2317. No existing issue found.
Checked and correct — no need to re-verify
ruff (check with violations / format --check / config error on stderr), mypy (type errors / bad config / fatal exit 2), go vet, go vet on a build failure, go test on a package that fails to build, cargo test passing and failing, pytest ordinary failure / all-green / exit 5, vitest all-green: byte-identical between develop and this head. The guard is correctly inert on all of them.
Two real catches worth recording, because they are the reason this should land:
rtk vitest runwhere a test file fails to load:developprintsPASS (2) FAIL (0)— a straight green lie, and exactly #4171 and #3935. This PR printsPASS (2) FAIL (1)and names the file.rtk cargo nextest runwith 1 failure and 1 skip:developprintscargo nextest: 2 passed. This PR replaces it with a failure verdict. The underlying misparse is pre-existing, and the guard stops it becoming a wrong answer.
Hypotheses built and dropped
looks_failedmisses a singular "1 issue" from ruff / golangci-lint. Dropped: both areformat!("… {} issues in {} files"), always plural, and a real 2-violationruff checkis byte-identical between the two binaries.failure_fallbackloses content versusdevelop's 2000-char passthrough on a pytest collection error. Dropped as a content-loss claim: with colour off the two binaries are byte-identical on that case. It only diverges under forced colour, which is blocker 3's evidence, not a separate finding.- The
go test -jsonfallback dumps raw NDJSON events. Not reproduced —run_testusesRunOptions::with_tee, and all three go arms came out byte-identical. I could not construct a real input that reaches the fallback there; flagging it as unverified rather than filing it. - The nextest
slowregex is a regression. Dropped:developdrops1 skippedfrom the same real line. Pre-existing, hence the issue draft above rather than a finding. - The guard should also cover
rspec/phpunit/mvn/bun/deno. Not a finding — the guard does not exist ondevelop, so the bar is whether the new path works, not whether the subsystem is now complete. Follow-up issue.
Your questions
- "If you'd rather have agents treat exit 1 as a real failure after all, that's a deliberate change to that test and I'm happy to do it as a follow-up." (golangci-lint exit 1 → 0) — keep it exactly as you have it.
lint_issues_are_summarised_and_exit_zeropins the design of record: lint findings are reported, not escalated into a build failure. Guarding the output side only is the right read of it, and no follow-up is needed. - "could you approve/re-run the workflow for the new head?" (18, 19 and 20 Sep) — done; the run on
234b1ebbfinished and all 11 checks are green. Nothing further needed from you there.
Next
Blockers 1 and 2 are the two-line patch above, already verified against the real tools. Blocker 3 needs your call between shrinking failure_fallback's overhead and exempting the verdict from never_worse as cargo already does. With those three, this merges.
Rounds: 1/3. Threads: 0 open, 0 resolved this round (none were open).
| total: json.num_total_tests, | ||
| passed: json.num_passed_tests, | ||
| failed: json.num_failed_tests, | ||
| failed: json.num_failed_tests + failed_suites, |
There was a problem hiding this comment.
Blocks merge (1/3). num_failed_test_suites is not a count of files that failed to load — vitest sets it, and testResults[].status == "failed", for any file that merely contains a failing test. Adding it to num_failed_tests double-counts every ordinary failing run.
Real vitest@3, 2 failing tests in 1 of 2 files (numFailedTests: 2, numFailedTestSuites: 1, fail.test.js status=failed assertionResults=3):
develop: PASS (2) FAIL (2)
this PR: PASS (2) FAIL (3)
FAIL (3) against total: 4 with 2 passing. The discriminator is whether the file ran any tests: a genuine load failure reports status=failed with assertionResults=0. Patch verified against all three arms in the review body.
There was a problem hiding this comment.
Round 2: still open, and the real-producer run says this is worse than round 1 described. The suite loop runs before the per-test loop and format_compact shows only the first five failures, so once four or more files have a failing test the fabricated entries crowd the real ones out:
real vitest@3, 6 failing tests in 6 files — every file loaded fine
develop: PASS (0) FAIL (6) 5735 B, 5 AssertionError messages shown
head: PASS (0) FAIL (12) 901 B, 0 AssertionError messages shown
1-5. [suite] …/src/fN.test.js
test file failed to load (…/src/fN.test.js)
... +7 more failures
Every real failure message is gone, replaced by a false cause, and the output shrinks — so it reads as a savings win while carrying no true diagnostic.
A fix passes when: a run with N failing files whose tests merely failed prints FAIL (N) with no [suite] entry and shows the real assertion messages; the suite-load arm keeps FAIL (1) naming the file; the all-green arm is unchanged; and a test pins the first arm — mutating the suite condition to fire unconditionally has to fail it.
| /// "20 failed" (counts merely ending in a zero) read as failures. | ||
| fn has_failed_word(text: &str) -> bool { | ||
| static FAILED: LazyLock<Regex> = | ||
| LazyLock::new(|| Regex::new(r"(?:(\d+)\s+)?failed\b").unwrap()); |
There was a problem hiding this comment.
Blocks merge (2/3). No leading word boundary, so this matches the failed inside xfailed with group 1 empty, and is_none_or returns true — looks_failed says the output already communicates failure and the guard stays silent.
FAILURES_WORD (line 57) has the same shape and independently matches the failure in pytest's green Expected-failure outcomes: header, which rtk elicits itself by passing -rxX. Either one alone is enough.
Real pytest, all tests green, one xfail, exit 1 forced by a pytest_sessionfinish gate (the pytest-cov --cov-fail-under mechanism):
raw: 2 passed, 1 xfailed in 0.01s exit 1
this PR: Pytest: 2 passed, 0 failed, 1 xfailed
Control with the same gate and no xfail: the guard fires correctly. \bfailed\b here and (?:^|[^-\w])failures?\b on line 57 fix both; verified on four arms with cargo test --all green.
There was a problem hiding this comment.
Round 2: still open. Re-verified on f140823 against the rebased base, which now adds a duration suffix to the pytest summary — that does not change the outcome:
real pytest, 2 green + 1 xfail, exit 1 from a pytest_sessionfinish gate
raw: 2 passed, 1 xfailed in 0.01s exit 1
head: Pytest: 2 passed, 0 failed, 1 xfailed in 0.01s ← green verdict on a non-zero exit
control, same gate, no xfail: guard fires correctly
A fix passes when the xfail arm no longer renders a green verdict, the control arm is unchanged, the real-failure arm keeps Pytest: 1 passed, 1 failed, and the exit-0 arm is unchanged. Both this regex and FAILURES_WORD have to stop matching — \bfailures?\b matches the failure in pytest's own green Expected-failure outcomes: header, which rtk elicits itself with -rxX.
| } | ||
|
|
||
| let mut result = format!("{}: failed (exit {})", tool, exit_code); | ||
| result.push_str("\n═══════════════════════════════════════\n"); |
There was a problem hiding this comment.
Blocks merge (3/3). This separator is 117 bytes of UTF-8 carrying no information, and together with the 26-char header it gives failure_fallback ~145 bytes of fixed overhead before any content.
At 8 of the 9 call sites guard_exit's result then passes through never_worse in run_captured_filter, so when the raw output is small — exactly the "green summary, tiny output" case this guard exists for — the whole guarded body loses the token comparison and never_worse discards the verdict, the numbered tail and the tee hint:
pytest, green + forced exit 1, this PR:
.. [100%]
2 passed in 0.00s
The invariant survives, but pytest: failed (exit 1) never appears. With FORCE_COLOR=3 set (Claude Code sets it) the same mechanism regresses the PR's headline case: pytest collection error goes from 818 B ANSI-stripped with a tee hint on develop to 924 B of raw ANSI with no hint — failure_fallback never calls strip_ansi.
CargoTestHandler::format_summary is the one genuinely exempt site. Your call between shrinking the overhead here and giving the verdict that same exemption at the other eight.
There was a problem hiding this comment.
Round 2: still open. develop's new clean_outputs bypasses never_worse only when raw is exactly a declared clean form, so it does not reach this path — the other eight call sites are unaffected.
pytest collection error, exit 2, FORCE_COLOR=3 (Claude Code sets it)
develop: 821 B, 0 ANSI escapes, tee hint present, 16.1% saved
head: 980 B, 9 lines carrying ANSI, no tee hint, no verdict, −0.1% saved
colour off: byte-identical to develop, and the verdict still never appears
A fix passes when, under FORCE_COLOR=3, the collection-error arm is at or below develop's 821 B with zero ANSI escapes and the tee hint intact, and <tool>: failed (exit N) actually appears on the green-plus-forced-exit control arm.
234b1eb to
f140823
Compare
|
Thank you for reviews. Update: Resolved merge conflict with
Ready for re-review when bandwidth allows. |
KuSh
left a comment
There was a problem hiding this comment.
Round 2 of 3 — CHANGES REQUESTED
Claim: no tool filter may render an all-green summary when the child process exited non-zero; a looks_failed denylist decides, and anything not provably communicating failure is replaced with a standardized <tool>: failed (exit N) verdict.
Scope: accept (round 1, frozen).
Ran: merge-base 7efbaef vs head f140823, one CARGO_TARGET_DIR each (md5 78a4aa9e / 165d2958, confirmed distinct before reading any transcript).
pytest 9.1.1 × {collection error (exit 2) × colour on/off, ordinary failure, all-green, green + xfail with a plugin-forced exit 1, control green + forced exit 1 without xfail} · vitest 3 × {ordinary failures (1 and 6 failing files), suite fails to load, all-green} · ruff 0.16.6 × {check with violations, clean [] run, --fix --exit-non-zero-on-fix} · cargo test {fail} · cargo-nextest 0.9.143 {2 passed (1 slow), 1 failed, 1 skipped}.
Invariants: every branch of the new decision; split streams; under the hook (rtk rewrite "vitest run" → rtk vitest); FORCE_COLOR on and off, which changes the outcome; the 48 develop commits the rebase brought under the PR — runner.rs +375 (the never_worse path), pytest_cmd.rs +79 (duration suffix), ruff_cmd.rs +10 (clean_outputs).
Savings, base → head: pytest ordinary failure 82.4% → 82.4%, green + xfail 71.5% → 71.5%, ruff check 21.0% → 21.0%, pytest collection error 34.8% → 34.8% with colour off but 16.1% → −0.1% under FORCE_COLOR=3 (20% floor, CONTRIBUTING.md). vitest is not a savings comparison — rtk injects --reporter=json, so raw is not the baseline; base → head is +161 B on the one-file failing arm, +168 B on the suite-load arm, and −4834 B on the six-file arm — smaller and carrying no true diagnostic (finding 1).
Matrix: executed this round on f140823. Reused from round 1 only where the head file is byte-identical and the base did not drift between the two merge-bases: go_cmd, golangci_cmd, lint_cmd, gradlew_cmd, mypy_cmd, cargo_cmd. pytest_cmd, ruff_cmd and guard.rs all changed, so every row touching them was rerun.
CI @f1408232d: the run was sitting at action_required and had never built — I approved it. All 11 checks green now (ubuntu/macos/windows tests, clippy, fmt, benchmark, security scans, doc review, test presence, CLA).
Merge check: merging f140823 into current develop (b931928) is clean; cargo test --all and cargo clippy --all-targets both exit 0 on the merged tree. That is a local gate, not CI: several suites in it read developer-local state (tracking DB, ~/.config), so it is evidence about this machine, not about a clean runner.
Blocks merge (3, frozen at round 1; +1 new regression in f140823)
The push is a rebase plus a five-line doc-comment merge in guard.rs. vitest_cmd.rs is byte-identical to the round-1 head, and the three blocking regions are untouched, so all three threads above stay open. Re-verified by execution on the new head rather than carried over:
1. src/cmds/js/vitest_cmd.rs:91 and :122 — ordinary failing vitest runs are overcounted, and at four or more failing files the real diagnostics are replaced by false ones. Unchanged in the code; worse than round 1 described it, because the suite loop runs before the per-test loop and format_compact only shows the first five failures.
real vitest@3, 6 failing tests in 6 files — every file loaded fine
develop: PASS (0) FAIL (6) 5735 B, 5 AssertionError messages shown
head: PASS (0) FAIL (12) 901 B, 0 AssertionError messages shown
1. [suite] …/src/f1.test.js
test file failed to load (…/src/f1.test.js)
… 2-5 the same for f2-f5 …
... +7 more failures
Every one of the six real failure messages is pushed behind +7 more failures and replaced by a fabricated "failed to load" for a file that loaded fine. The output also gets smaller — 5735 B down to 901 B — so it reads as a savings win while carrying no true diagnostic at all. The two-file arm is the milder form of the same thing: FAIL (2) → FAIL (3), +161 B, with the fabricated entry first.
Accepted when: a run with N failing files whose tests merely failed prints FAIL (N) with no [suite] entry and shows the real assertion messages; the suite-load arm keeps FAIL (1) naming the file; the all-green arm is unchanged; and a test pins the first arm — mutating the suite condition to fire unconditionally must fail it.
2. src/core/guard.rs:86 — a single xfail anywhere in a pytest suite switches the guard off. Unchanged, and the base's new duration suffix does not help.
real pytest, 2 green + 1 xfail, exit 1 forced by a pytest_sessionfinish gate
raw: 2 passed, 1 xfailed in 0.01s exit 1
head: Pytest: 2 passed, 0 failed, 1 xfailed in 0.01s ← all-green verdict on a non-zero exit
control, same gate, no xfail: guard fires
Accepted when: the xfail arm no longer renders a green verdict; the control arm is unchanged; the real-failure arm keeps Pytest: 1 passed, 1 failed; the exit-0 arm is unchanged. Both has_failed_word and FAILURES_WORD (\bfailures?\b, which matches the failure in pytest's own green Expected-failure outcomes: header that rtk elicits with -rxX) have to stop matching for the arm to pass.
3. src/core/guard.rs:118 — the verdict never reaches the user, and under colour the fallback is worse than what develop printed. Unchanged. develop's new clean_outputs bypasses never_worse only when raw is exactly a declared clean form, so the other eight call sites are unaffected by it.
pytest collection error, exit 2, FORCE_COLOR=3 (Claude Code sets it)
develop: 821 B, 0 ANSI escapes, tee hint present, 16.1% saved
head: 980 B, 9 lines carrying ANSI, no tee hint, no verdict, −0.1% saved
(colour off: byte-identical to develop, and still no verdict)
pytest green + forced exit 1, head:
.. [100%]
2 passed in 0.00s ← raw; "pytest: failed (exit 1)" never appears
failure_fallback carries ~145 bytes before any content — a 26-char header plus "═" * 39, 117 bytes of UTF-8 — and never calls strip_ansi. Accepted when: under FORCE_COLOR=3 the collection-error arm is at or below develop's 821 B with zero ANSI escapes and the tee hint intact, and the verdict actually appears on the control arm.
4. src/cmds/python/ruff_cmd.rs:121 — NEW in this push: a non-zero exit that is not a failure now reports failure. The guard reads "exit ≠ 0" as proof the run failed. ruff check --exit-non-zero-on-fix exists to tell CI "I changed files", not "I failed":
ruff check --fix --exit-non-zero-on-fix .
raw ruff: Found 2 errors (2 fixed, 0 remaining). exit 1
(under rtk's injected --output-format=json: stdout is "[]", exit 1)
develop: Ruff: No issues found 22 B rtk exit 1
head: ruff: failed (exit 1) 146 B rtk exit 1
═══════════════════════════════════════
1. []
The run succeeded — both violations were fixed — and the PR calls it a failure, with [] as the evidence. This is new in f140823 rather than a round-1 miss, and worth saying where it came from: clean_outputs landed on develop in bff334ca (#4266) four days after round 1. Before it, a clean ruff check printed the raw [] on both sides, because never_worse preferred 2 bytes of [] over the 29-byte summary — so at round 1 this case was identical on both sides and there was nothing to see. #4266 made the summary win for exactly that input, and this PR's guard now replaces it with a failure verdict. The two changes are individually reasonable and wrong together.
I walked the other eight wirings for the same shape: golangci-lint exit 1 (issues found) and go vet exit 1 both land on summaries that looks_failed matches, ruff format --check matches NEED_FORMATTING, and the rest have no such flag. Ruff is the only affected call site today, but the premise is in guard_exit, so a new flag or tool inherits it.
Accepted when: ruff check --fix --exit-non-zero-on-fix keeps Ruff: No issues found, while ruff with a config error (exit 2, empty stdout) still refuses to print a green verdict.
Optional — will not hold merge
These are round-1 code rather than anything the push introduced, so they are not holding merge, but the first one is well grounded in the repo's own reasoning:
failure_fallbacktakes the first 20 lines (lines.iter().take(…)) while its own doc comment promises a "capped raw tail".runner.rs:214-216already argues the opposite end for the same situation: "The cap keeps the END. A tool resolves and downloads before it builds, so its chatter comes after: a cap on the head keeps the noise and drops exactly the lines worth forwarding." On the real failingcargo nextest runthe 20 lines went onFinished test profile, a box rule, the run ID andStarting 3 tests…, and cut off beforefailures:. Tail-capping would make the comment true and the fallback useful.vitest_cmd.rs:286guardsrendered, whichrender_test_outputhas already appended the tee hint to, and then passesNoneas the hint toemit_guarded— so when the fallback fires the recovery pointer goes with it.lint_cmd.rs:240-243does it the other way round (hint computed separately, guard first, hint passed toemit_guarded) and keeps the pointer. Worth making vitest match its sibling in the same PR.- The
(N slow)nextest annotation is unrelated to the exit guard and would read better as its own PR. Its misparse is pre-existing —developdropsfailed/skippedfrom the same real line, and the multi-annotation form(1 slow, 1 leaky)fails to match on both sides — so it is drafted as a separate issue rather than asked for here. The two new tests assert301 passed, 0 skipped (0 slow)and50 passed, 3 skipped (2 slow), orderings nextest never emits, which is why they pass without touching the real format. - The description still lists
tscandjest, neither of which this PR touches.
Filed as follow-ups
Both were drafted at round 1 and held for the release, which has since shipped as v0.51.0. Neither had an existing issue (searched open and closed, by symptom and by mechanism):
- #4420 —
cargo nextestdropsN failed/N skippedwhenever the(N slow)annotation is present. Pre-existing ondevelop, so nothing for this PR to do; filed so the(N slow)work has somewhere to land. - #4421 — the exit guard covers 9 filters;
rspec,phpunit,mvn,bun,deno,ctestandsbtstill render green summaries without consulting the exit code. Not a regression — the guard is new — but it is the rest of #2317.
Checked and correct — no need to re-verify
Rerun on this head and byte-identical between develop and the PR: pytest ordinary failure / all-green / control-without-xfail, ruff check with violations, ruff check on a clean [] run (the new clean_outputs path), cargo test with a failing test. Reused from round 1 on byte-identical head files with no base drift: go vet, go vet on a build failure, go test on a package that fails to build, mypy (type errors / bad config / fatal exit 2).
The two catches that are the reason this should land are both still there on the new head: rtk vitest run with a suite that fails to load goes from develop's PASS (2) FAIL (0) to PASS (2) FAIL (1) naming the file (#4171, #3935), and rtk cargo nextest run with 1 failure and 1 skip goes from develop's cargo nextest: 2 passed to a failure verdict.
Your questions
- "Resolved merge conflict with
develop… now mergeable:true, checks pending. Ready for re-review when bandwidth allows." → Confirmed: the merge into currentdevelopis clean and the post-merge gate passes locally. The CI run on this head had never actually built — it was waiting on maintainer approval — so I approved it; no action needed from you there. The rebase did not change any of the three blocking regions, and it introduced finding 4.
Next
Findings 1-3 are the round-1 set, unchanged and re-verified on this head; 4 came in with the rebase. Each one above states the matrix rows a fix has to pass, which is what I will re-run. With those four, this merges.
Rounds: 2/3. Threads: 4 open (3 carried from round 1, waiting on the fixes; 1 new), 0 resolved this round.
| }; | ||
| // "All files formatted correctly" / "No issues found" must never | ||
| // be shown when ruff exited non-zero (config error, etc.). | ||
| crate::core::guard::guard_exit(stdout, exit_code, "ruff", &filtered) |
There was a problem hiding this comment.
Blocks merge (4/4) — new in this push. guard_exit reads "exit ≠ 0" as proof the run failed. ruff check --exit-non-zero-on-fix exists to tell CI "I changed files", not "I failed", so this turns a successful run into a reported failure:
ruff check --fix --exit-non-zero-on-fix .
raw ruff: Found 2 errors (2 fixed, 0 remaining). exit 1
(under rtk's injected --output-format=json: stdout is "[]", exit 1)
develop: Ruff: No issues found 22 B rtk exit 1
head: ruff: failed (exit 1) 146 B rtk exit 1
═══════════════════════════════════════
1. []
Both violations were fixed — the run succeeded — and the verdict says it failed, with [] as the evidence.
Worth saying where this came from, because it is not a round-1 miss: clean_outputs landed on develop in bff334ca (#4266) four days after round 1. Before it, never_worse preferred 2 bytes of [] over the 29-byte summary, so both sides printed [] and there was nothing to see. #4266 made the summary win for exactly that input, and this PR's guard now replaces it with a failure verdict. The two changes are individually reasonable and wrong together.
I walked the other eight wirings for the same shape: golangci-lint exit 1 (issues found) and go vet exit 1 land on summaries looks_failed matches, ruff format --check matches NEED_FORMATTING, and the rest have no such flag. Ruff is the only affected call site today, but the premise is in guard_exit, so a new flag or tool inherits it.
Accepted when ruff check --fix --exit-non-zero-on-fix keeps Ruff: No issues found, while ruff with a config error (exit 2, empty stdout) still refuses to print a green verdict.
|
Thank you @KuSh for the precise round-2 review — all three blocking findings are addressed in e2e681b, verified against every arm in the review body. 1/3 — vitest double-count ( 2/3 — 3/3 — separator overhead ( I work across the full stack from web frontends down through compilers to assembly and kernel code, so I take denylist-contract bugs like these seriously — a guard that stays silent on green-with-nonzero-exit is worse than no guard. Happy to iterate further. The branch also picks up the latest |
Extend the never-worse guard with the exit-code invariant: a filter must never render an all-green summary when the child process exited non-zero. On a non-zero exit a filter result is kept only when it already communicates failure (looks_failed, a DENYLIST of failure shapes: non-zero failed/errors/issues/warnings/problems counts, bare FAIL, compiler error lines, panics/exceptions, needs-formatting verdicts); otherwise it is replaced with a standardized '<tool>: failed (exit N)' verdict with a capped raw tail (failure_fallback). Module doc preserves upstream's never-worse output-guard documentation and adds the exit-guard contract.
Add a shared exit-code guard (src/core/guard.rs) that replaces any all-green filter verdict with '<tool>: failed (exit N)' plus a raw tail when the child exits non-zero but the parsed output still looks clean. Fixes Issue rtk-ai#2317: filters must never be worse than the raw command. Apply guard_exit to: pytest, ruff, mypy, cargo test, cargo nextest, vitest (numFailedTestSuites + success fields), tsc (position-less global errors like TS5083), generic lint (ZERO_ERRORS regex avoids '10 errors' false positive), go vet, go test -json (covers killed exit 137), and golangci-lint (exit 1 no longer rewritten to 0). Add nextest '(N slow)' summary annotation parsing and unit tests for each guard point. (cherry picked from commit c15ccb5)
…guard - looks_green: match '0 failed' via digit-boundary regex so pytest verdicts like '44 passed, 0 failed, 3 skipped' stay green while '10 failed' stays red - document the allowlist contract and add guard_catches_known_green_verdicts test so new green strings can't silently bypass the guard - gradlew: route Test/ConnectedTest/Lint/Dependencies filters through run_filtered_with_exit + guard_exit so a crashed connected test run can't render 'ok ✓ (connected tests passed)' (cherry picked from commit 40934da)
…ndary, separator overhead - vitest: count only genuine load failures (status=failed with zero assertionResults) on top of numFailedTests; files merely containing failing tests no longer double-count (FAIL (3) vs total 4 fixed). - guard: add word boundary before 'failed' so 'xfailed' no longer reads as failure; scope FAILURES_WORD with (?:^|[^-\w]) so pytest's green 'Expected-failure outcomes:' header no longer matches. - guard: replace 117-byte UTF-8 separator with 5-byte ASCII rule so failure_fallback survives the never_worse token comparison on small raw outputs. Verified against all arms in the review body; regression tests added.
…ndary, separator overhead, ruff fix-flag - vitest: drop numFailedTestSuites field, only genuine load failures get [suite] entry, real assertion messages survive - guard: has_failed_word/FAILURES_WORD no longer match xfailed / Expected-failure header - failure_fallback: strip ANSI, 5-byte rule, tee hint re-attached on guard-fire path - ruff: --fix/--fix-only + --exit-non-zero-on-fix exit 1 keeps No issues found, config error exit 2 still fails - rebase onto upstream develop, keep clean-outputs + exit invariant
e2e681b to
8eb43a4
Compare
|
@KuSh — thank you for the precise round-2 review. All four blocking findings are addressed in 1/4 — vitest double-count ( 2/4 — 3/4 — separator overhead + ANSI ( 4/4 — ruff Verification: I'm a full-stack engineer working from frontends down through compilers to assembly and kernels, so I take denylist-contract bugs seriously — a guard silent on green-with-nonzero-exit is worse than none. CI on the fork needs maintainer approval — @pszymkowiak, could you approve the workflow for the new head? — Hesam |
|
Thank you @KuSh for the precise round-2 matrix — ran it line by line on my side, all four arms reproduced before the fix. Pushed 1/4 — vitest double-count ( 2/4 — xfailed boundary ( 3/4 — separator overhead ( 4/4 — ruff Full-stack check across layers: Rust guard core, JS vitest JSON shape, Python pytest/ruff producer strings — all covered by new tests ( CI on |
Implements the cross-cutting exit-code guard from Issue #2317.
Problem
Tool filters rendered all-green summaries even when the child
process exited non-zero, masking hard failures (collection errors,
compile errors, OOM kills) as benign states.
Fix
Added
guard::never_worsehelper following the existingfilter_go_build_with_exitpattern. Applied to all affected filters:Verification
Closes #2317