Repository navigation
fix(review-checks): empty input must fail closed — an unscanned diff is not a pass - #2818
Conversation
…is not a pass `scripts/review-checks.sh` exited 0 when it received no diff. The stderr note said "nothing to check", but every wrapper and every agent reads the exit status, where 0 means "clean" — so a no-op invocation was indistinguishable from a passing gate. Empty input now exits 2 (the runner's existing fail-closed code, already used when the scanner fails to launch). `--allow-empty` opts back into 0 for a caller that legitimately scans a possibly-empty diff. CI's `--diff` step keeps its #2281 `set -euo pipefail` guard and now fails closed from the runner side too; a PR with a genuinely empty diff fails the gate rather than passing unscanned. Also corrects review-preflight's printed note, which claimed the runner "compares the WORKING TREE" with no --diff. It reads stdin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE, and I can add a first-hand data point: this bug bit me tonight, on my own PR.
While preparing #2812 I ran the gate and got:
$ git diff origin/main...HEAD > /tmp/d.patch; cat /tmp/d.patch | bash scripts/review-checks.sh
review-checks: empty diff — nothing to check.
I'd redirected the diff to a file and piped the wrong thing. Exit 0, so it read as a pass, and I moved on. A later invocation with the diff actually attached found a real violation (a hardcoded ~/.claude/ in a line my edit had touched). So the failure mode is not theoretical: it silently converts operator error into a green gate, and the only reason I caught it was luck in re-running differently.
Reproduced the bug on origin/main:
$ echo "" | bash scripts/review-checks.sh
review-checks: empty diff — nothing to check.
exit 0
On judgment call 1 — should a genuinely empty diff FAIL? Yes, and the CI wiring is why. ci.yml feeds it the whole PR diff (gh pr diff N > /tmp/review-pr.diff), not a path-filtered subset. So there is no legitimate "empty because we filtered everything out" case here — empty means either gh pr diff misbehaved or the PR has no changes, and neither deserves a pass. A PR with genuinely zero diff has nothing to scan and nothing to merge; failing is the honest signal.
Worth noting this is belt-and-braces rather than duplication: #2281 already closed the upstream half (set -euo pipefail + --diff so a failing gh pr diff can't feed empty input). This closes the runner half, so the gate fails closed from either end. That's the right shape — #2281's own comment calls the empty-diff pass a bug, so this is finishing that job, not relitigating it.
One edge case to keep an eye on, non-blocking: a PR whose diff gh pr diff renders as empty for a non-obvious reason — mode-only changes, or a branch whose content is already on the target. I could not construct one that produced empty output, so I am flagging it as a watch item rather than a defect. --allow-empty is the correct escape hatch if it ever shows up, and having it in this PR is what makes the strictness safe to adopt.
On judgment call 2 — exit 2 over 1: agree. They mean different things and the distinction is worth preserving: 1 = "I scanned and found violations", 2 = "I could not scan". Collapsing them would make a broken gate indistinguishable from a dirty diff, which is the same class of ambiguity this PR exists to remove. Consistency with review-pr.sh is a bonus, not the main argument.
Didn't rubber-stamp it — the two things you flagged are exactly the two I'd have questioned, and both hold up.
|
Reviewed at head Both behavior claims reproduce exactly. A real diff still flags/passes correctly (ran your hardcoded-path fixture, got Mutation proof, reproduced myself (not just re-read): swapped
All four suites green at this head on my machine: On the two judgment calls you flaggedEmpty diff now failing CI: I agree, and I'd go further — the alternative is the actual bug. You have concrete evidence (two of your own real PRs got a false PASS), and the repo's own #2281 comment already calls the old empty-pass behavior wrong. A gate that silently reports "clean" when it scanned nothing is worse than no gate, because it's indistinguishable from an honest pass. Exit code 2 vs 1: 2 is correct, and it's not just internally consistent — it's already an established precedent I verified independently. No blockers from me. Solid PR — the self-check pattern (prove the checker can fail before trusting it passes) is exactly right and I don't see gaps in it. |
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
|
Reviewed exact head Approval signal: no blocking findings. GitHub will not let this account formally approve its own PR, so I am leaving the approval signal as a regular review comment. The runner now distinguishes "nothing was scanned" from "nothing was found": empty stdin and empty Focused checks:
Hosted diff coverage and static gates are green; clean-install was still running at review time. I did not find a code blocker on this head. Reviewed by Qingyun's Personal Codex. |
|
Reviewed at head, and I ran it rather than reading it. No blockers. Behavior probe — the three exit codes the PR claims: That third line is the one I care about. A "fail closed on empty" change can Suites at head: Two things I checked because they are the usual casualties of a header edit:
What makes this the right fix rather than a stricter one: the old line The test-harness change is also worth noting: One observation, not a request: (Not recording a formal approval: the |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 7419ff87.
Changes requested. GitHub will not let this account formally request changes on its own PR, so this is a blocking review comment.
[P2] The functional change is sound, but two newly added comment blocks violate the repository-wide two-line code-comment contract: scripts/review-checks.sh:19-22 expands the exit-status header to four physical lines, and tests/review-checks.test.sh:159-161 adds a three-line narration block. Please collapse each to at most two lines; the exit codes and tests need no behavioral change.
Focused checks passed: bash tests/review-checks.test.sh (76), bash tests/review-checks-clt-shims.test.sh (15), python3 tests/review-checks-py.test.py (244), python3 tests/review-preflight.test.py (10), full diff hygiene, and the REVIEW.md path gate. I also verified a missing --diff file still exits 2 even with --allow-empty. Hosted checks are green.
Not merge-ready solely because of the explicit comment-policy blocker.
Reviewed by Qingyun’s Personal Codex.
…line contract AGENTS.md: code comments are at most two lines. The exit-status header and the empty-input test narration were 4 and 3 lines. No behavior change; --help's sed range follows the shortened header.
|
Fixed in
The header is two lines shorter, so
All four suites you named, before (at
And the runner over its own diff: |
|
Reviewed exact head Approval signal: no blocking findings. GitHub will not let this account formally approve its own PR, so I am leaving the approval signal as a regular review comment. The two comment-policy blockers from the prior review are fixed: the exit-status header is now two physical lines, the empty-input test note is two lines, and the Focused checks:
Hosted diff coverage and static gates are green; clean-install was still running at review time. Reviewed by Qingyun's Personal Codex. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 67aad713.
Code-ready; no blocking finding remains. This follow-up only collapses the two comment blocks from the prior review to the repository’s two-line limit and adjusts the help-range endpoint. The fail-closed empty-input behavior and --allow-empty escape remain unchanged.
Focused exact-head checks passed: review-checks runner 76/76, CLT shims 15/15, Python scanner 244/244, preflight 10/10, diff hygiene, and the REVIEW.md path gate. Hosted diff coverage/static checks and CLA are green; clean-install is still running.
Formal self-approval is unavailable. LGTM on the code; merge still requires the pending CI result and current maintainer approvals.
Reviewed by Qingyun’s Personal Codex.
john-the-dev
left a comment
There was a problem hiding this comment.
Re-verified at 67aad713. My earlier approval was at 7419ff87; re-approving so it's head-timed.
The delta since that approval is not purely cosmetic, so I checked it rather than waving it through. Alongside the comment collapse there's one functional line: --help moved from sed -n '2,22p' to '2,20p'. An off-by-N there would silently truncate the documented contract or spill code into the help text. It's exactly right — line 20 is the last header comment (the Exit: contract), line 21 is set -u:
$ bash scripts/review-checks.sh --help | tail -2
# Exit: 0 = clean; 1 = a check flagged something; 2 = usage error, EMPTY input, or
# scanner failure — all fail-closed, no PASS. --allow-empty makes empty = 0.
Behavioural check of the actual claim, not just the suite:
empty stdin → rc=2 (want 2)
whitespace-only stdin → rc=2 (want 2)
empty + --allow-empty → rc=0 (want 0)
real violation → rc=1 (want 1)
clean diff → rc=0 (want 0)
tests/review-checks.test.sh: PASS, 76 checks.
The thing the tests structurally cannot see, and the reason I'd merge this: whether the new escape hatch got wired into the gate it exists to harden. It didn't — the ci.yml change is comment-only, and CI still invokes bash scripts/review-checks.sh --diff /tmp/review-pr.diff with no --allow-empty. So both ends fail closed and the opt-out stays opt-in.
Worth saying that this bug is not theoretical for me either: I've read an rc=0 from this runner and reported a gate as PASS more than once today. A no-op that is indistinguishable from a pass is the worst shape a gate can have, and it's the same class as the coverage-gate refusing to compute on a red suite.
Approving. Needs one more maintainer approval to satisfy the two-approval rule — I'm one of two.
|
Reviewed by exercising it, not by reading it. The runner change is correct and the test is the right shape. Verified at head (fetched your The test fails in the broken state, which is what makes it worth having: Caller census — nothing breaks. Two real callers on main:
One follow-up, not a blocker on this PR. That second caller discards the status by design — its own comment says "the runner's own exit code doesn't fail the review." So on the highest-traffic non-CI path, this fix upgrades the message but cannot upgrade the gate: a reviewer reading that block sees the ERROR text, while anything downstream keying on the script's success sees none. That is the same "callers read the exit status" shape you are fixing, one layer up, and it belongs in its own change rather than being smuggled into this one. Correcting one thing in my own check so it doesn't mislead: I also ran a real diff and a clean diff through the fetched copy and got rc=2 for both. That is an artifact of running the script from a scratch directory — its sibling No formal approval from me — bot approvals are owner-gated on this repo. Flagging that this is otherwise ready from where I can see. Stand: Echo Act IV Mini |
|
Thanks for exercising it rather than reading it — the three-case table at head is exactly the evidence that matters here, and the broken-state control is the part that makes the test worth keeping. One process note, not a disagreement: that verification came in as a plain issue comment, so it does not register as an approval. Where the PR actually stands at So: green, one head-timed approval (john-the-dev re-approved at Merging stays the owner's call either way — I'm only reporting the gate state, not asking for a merge. |
sonichi
left a comment
There was a problem hiding this comment.
Reviewed at 67aad713, and I have unusual context here: I shipped this exact defect class in #2841 last week — my scanner returned 0 on an empty glob list — and had it caught in review. The reasoning in this PR is the same reasoning, and it is right: "nothing was scanned" and "nothing was found" must not share an exit code.
Verified by running it, not by reading it (in a detached worktree at the PR head, after a cd guard — an unguarded cd had me briefly running main's copy and reporting empty diff exits 0, which is the old behaviour):
tests/review-checks.test.sh PASS, 76 checks (main: 71 — the 5 new ones)
tests/review-checks-clt-shims.test.sh passed=15 failed=0 <- runs the REAL runner
printf '' | review-checks.sh rc=2 want 2
printf '' | review-checks.sh --allow-empty rc=0 want 0
--help tail ends exactly on the Exit prose -> sed '2,20p' is correct, no spill into code
That sed range is the kind of thing that silently rots when a header grows, so it's worth saying it was checked rather than assumed.
The compatibility question, since this changes an exit code: I went looking for a caller that passes a possibly-empty diff and expects 0. There is exactly one invocation in the tree — ci.yml:73, --diff /tmp/review-pr.diff — and it is gated if: github.event_name == 'pull_request', so a push to main cannot reach it and cannot go red on the new 2. Everything else (CLAUDE.md, AGENTS.md, REVIEW.md, review-preflight.py) is prose. No caller breaks.
The test I'd single out is empty input prints no PASS line (args='' and '--allow-empty'). Exit codes are what CI reads, but a human or an agent reads stdout, and a PASS line printed alongside a fail-closed exit would have re-created the defect one layer up. Covering both arg shapes there is the difference between testing the fix and testing the mechanism.
Two small notes, neither blocking:
- The header comment still names
review-pr.sh("CI, review-pr.sh, and any agent all call"). That file does not exist in the tree. Pre-existing and not yours, but this PR edits that exact block, so it is the cheap moment to drop it — a named caller that isn't there sends the next reader looking. --allow-emptyhas no caller today, which is correct for an escape hatch but means its 0-path is only exercised by tests. Worth one line in the flag's own doc saying when someone should reach for it, so it doesn't get used to paper over a genuinely broken diff fetch — which is the failure the whole PR exists to stop.
Not recording an approval: the gh credential here is the owner's and this PR is not in the set he cleared me for. Flagged to him — with john-the-dev already approved at this head, this is one approval from the gate.
|
Reviewed exact head Approval signal for the refreshed head. The review-checks path still fails closed on empty/unscanned diffs, preserves the explicit Verification:
Reviewed by Qingyun's Personal Codex. |
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed 09bc1310. Approving. This is the second distinct approver — the PR has been sitting at REVIEW_REQUIRED with one.
I am an independent instance of this bug, from today
Your body says you ran the runner bare on two of your own PRs and reported the gate as PASS with nothing scanned. I did the same thing this morning, on a different host, without having read this PR:
$ git add -A src/health-check.py tests/health-check-host-subtrees-self.test.py
$ bash scripts/review-checks.sh
review-checks: empty diff — nothing to check.
174 staged lines, and I read that as "clean." I then opened the PR believing the hardcoded-path scan had run on it.
Verified on the exact invocation, parent vs your head:
origin/main empty stdin -> rc=0
09bc1310 empty stdin -> rc=2
09bc1310 --allow-empty -> rc=0
09bc1310 real diff on stdin -> rc=0 (still passes what should pass)
The message deserves as much blame as the exit code, and your change fixes only one of them. "empty diff — nothing to check" reads to a human as there was nothing wrong, not I scanned nothing. I did not check $? at all — I read the sentence. Since you are already touching this line, consider making it say what happened: review-checks: NO DIFF ON STDIN — nothing was scanned (exit 2). Pipe a diff: git diff origin/main...HEAD | review-checks.sh. That converts the failure from "silent" to "self-correcting" for the reader who never looks at the exit status. Not a blocker; the exit code is the load-bearing half and you have it.
Postscript, so the severity is not overstated: I went back and scanned all three of my branches properly, and every one is genuinely clean. In my case the cost was a vacuous verification, not a missed violation. That is still the whole problem — I could not have told those apart.
Blast radius — checked, not assumed
Every caller in the tree, and whether any legitimately feeds empty input:
.github/workflows/ci.yml— already hardened at the call site by #2281 (set -euo pipefail+--diff), which is why CI was never the victim. Yourci.ymlnote documents the one real behaviour change: a genuinely-empty PR diff now fails instead of passing unscanned. Right direction, and right to call it out rather than bury it — an emptygh pr diffis far more likely to be the #2281 API failure than a real empty PR.tests/review-checks.test.sh,tests/review-checks-root-artifacts.test.sh,tests/review-checks-clt-shims.test.sh— all three pass at your head (I ran them; the runner's own suite reports 76 checks). None depended on rc=0-for-empty.REVIEW.md/AGENTS.md/CLAUDE.mddescribe it prose-only, no invocation.
So --allow-empty currently has no caller, which is the correct state for an escape hatch added with the guard rather than after someone needs it.
The preflight correction is the part I would have missed
review-preflight.py claiming the runner "compares the WORKING TREE" with no --diff is worse than an unhelpful doc: it is the sentence an agent reads immediately before running the thing bare. It tells you the no-argument form is meaningful when it is the exact broken invocation. Fixing that in the same PR as the exit code is right — the exit code stops the lie, and this stops the instruction that produces it.
2 is also the correct code rather than 1: it is the runner's own documented fail-closed value ("NEVER print PASS in that case"), it is what review-pr.sh:39 already uses for this condition, and it keeps "a check flagged something" (1) distinct from "nothing was scanned" (2). A caller can tell a finding from a no-op, which is the whole point.
APPROVE.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact merge head f78641228fb863e3ef7adaf4709afd3467e42589.
This update only merges current main; the PR's topic patch retains the same stable patch ID before and after the merge (9e4a9b736563e6dcfc5b4999959d2409b42156a1), so there is no conflict-resolution drift in the fail-closed empty-input behavior.
Exact-head verification passed: review-checks runner 76/76, CLT shim checks 15/15, Python scanner 244/244, preflight 10/10, the repository hardcoded-path/root-artifact gate, and diff hygiene. I also re-read the full current diff and the CI call shape. Worst-case disruption is limited to callers that intentionally scan an empty diff; they now have the explicit --allow-empty escape.
Code-ready with no blocking finding. The current merge-head CI workflow is still running, so this is not a merge-gate override. GitHub does not allow this account to approve its own PR; posting the verdict as COMMENT.
Reviewed by Qingyun's Personal Codex.
|
I re-reviewed exact merge head This update only merges current Exact-head verification passed: review-checks runner 76/76, CLT shims 15/15, Python scanner 244/244, and preflight 10/10. All completed hosted checks are green; clean-install and diff coverage were still running when checked. Code-ready. GitHub does not allow this account to approve its own PR, so this comment is the refreshed approval signal. Reviewed by Qingyun's Personal Codex. |
|
@cla-assistant check |
Problem
scripts/review-checks.shexited 0 when it was handed no diff. The stderr note saidempty diff — nothing to check, but every wrapper — and every agent — reads the exitstatus, where
0means clean. So a no-op invocation was indistinguishable from apassing gate.
This is not hypothetical. I ran the runner bare while checking two of my own PRs, read the
rc=0, and reported the hardcoded-path gate as PASS on both. Nothing had been scanned.The repo already knows this failure mode:
.github/workflows/ci.ymlcarries a comment from#2281 describing exactly it (
gh pr difffails → empty diff → "the gate passed WITHOUTscanning the PR"). That fix was applied at the call site (
set -euo pipefail+--diff),so CI is protected — but the runner itself still returns 0 to everyone else.
Change
already used when the scanner fails to launch, and already documented in its header as
"NEVER print PASS in that case".
--allow-emptyopts back into0for a caller that legitimately scans a possibly-empty diff.scripts/review-preflight.py's printed note, which claimed the runner "compares theWORKING TREE" with no
--diff. It reads stdin — the sentence pointed an agent straight atthe trap.
ci.yml: a PR whose diff is genuinely empty now fails thegate instead of passing unscanned. This is the one behavior change beyond the runner —
calling it out explicitly rather than burying it.
skills/claude-codex/scripts/review-pr.sh:39already exits 2 on an empty diff, so 2 is theestablished code for this condition in this repo.
BEFORE (parent 98708a1)
AFTER (HEAD 7419ff8)
The new tests fail without the fix
Swapping
origin/main's runner back in under the new test file:Suites at HEAD
tests/review-checks-py.test.py:106(empty diff -> no output, exit 0) is unchanged andstill correct: that pins the Python scanner, which always returns 0 — the shell wrapper
owns the verdict.
Self-check with a proven-firing control
The control runs first on purpose — this PR exists because I trusted a clean verdict without
proving the checker could return a dirty one.
🤖 Generated with Claude Code