Skip to content

fix(review-checks): empty input must fail closed — an unscanned diff is not a pass - #2818

Merged
john-the-dev merged 6 commits into
mainfrom
fix/review-checks-empty-input-fail-closed
Aug 14, 2026
Merged

john-the-dev merged 6 commits into
mainfrom
fix/review-checks-empty-input-fail-closed

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Problem

scripts/review-checks.sh exited 0 when it was handed no diff. The stderr note said
empty diff — 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.

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.yml carries a comment from
#2281 describing exactly it (gh pr diff fails → empty diff → "the gate passed WITHOUT
scanning 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

  • Empty (or whitespace-only) input now exits 2 — the runner's existing fail-closed code,
    already used when the scanner fails to launch, and already documented in its header as
    "NEVER print PASS in that case".
  • --allow-empty opts back into 0 for a caller that legitimately scans a possibly-empty diff.
  • Corrects scripts/review-preflight.py's printed note, which claimed the runner "compares the
    WORKING TREE"
    with no --diff. It reads stdin — the sentence pointed an agent straight at
    the trap.
  • Documents the CI consequence in ci.yml: a PR whose diff is genuinely empty now fails the
    gate 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:39 already exits 2 on an empty diff, so 2 is the
established code for this condition in this repo.

BEFORE (parent 98708a1)

$ printf "" | bash scripts/review-checks.sh; echo rc=$?
review-checks: empty diff — nothing to check.
rc=0

$ : > /tmp/e.diff; bash scripts/review-checks.sh --diff /tmp/e.diff; echo rc=$?
review-checks: empty diff — nothing to check.
rc=0

AFTER (HEAD 7419ff8)

$ printf "" | bash scripts/review-checks.sh; echo rc=$?
review-checks: ERROR — empty diff; nothing was scanned, so this is NOT a pass.
  Pipe a diff, or pass --diff <file>; use --allow-empty to accept an empty input.
rc=2

$ : > /tmp/e.diff; bash scripts/review-checks.sh --diff /tmp/e.diff; echo rc=$?
review-checks: ERROR — empty diff; nothing was scanned, so this is NOT a pass.
  Pipe a diff, or pass --diff <file>; use --allow-empty to accept an empty input.
rc=2

$ printf "" | bash scripts/review-checks.sh --allow-empty; echo rc=$?
review-checks: empty diff — nothing to check (--allow-empty).
rc=0

# unchanged: a real diff still passes / still fails
$ printf "+++ b/x.sh
@@ -1,0 +1,1 @@
+CCD=/Users/alice/workspace
" | bash scripts/review-checks.sh; echo rc=$?
review-checks: FAIL — hardcoded-paths:
x.sh:1: hardcoded path (/Users/alice/workspace): CCD=/Users/alice/workspace
review-checks: 1 violation(s). Resolve via workspace/config helpers, or add a scoped allow to REVIEW.md if it's a genuine fixture.
rc=1

The new tests fail without the fix

Swapping origin/main's runner back in under the new test file:

$ git show origin/main:scripts/review-checks.sh > scripts/review-checks.sh
$ bash tests/review-checks.test.sh | grep -E '^FAIL|^FAILED'
FAIL empty stdin fails closed (rc=2, never a pass) (rc=0, want=2)
FAIL whitespace-only stdin fails closed too (rc=0, want=2)
FAIL --allow-empty opts an empty input back into 0 (rc=2, want=clean)
FAIL empty --diff file rc=0, want 2
FAILED — 4 of 76 checks

Suites at HEAD

$ bash tests/review-checks.test.sh | tail -1
PASS — review-checks runner (76 checks)

$ bash tests/review-checks-clt-shims.test.sh | tail -1
passed=15 failed=0

$ python3 tests/review-checks-py.test.py | tail -1
PASS — review-checks.py internals (244 checks)

$ python3 tests/review-preflight.test.py 2>&1 | tail -1
OK

tests/review-checks-py.test.py:106 (empty diff -> no output, exit 0) is unchanged and
still correct: that pins the Python scanner, which always returns 0 — the shell wrapper
owns the verdict.

Self-check with a proven-firing control

$ printf '+++ b/x.sh\n@@ -1,0 +1,1 @@\n+CCD=/Users/alice/workspace\n' | bash scripts/review-checks.sh; echo rc=$?
review-checks: FAIL — hardcoded-paths: ...
rc=1

$ git diff origin/main...HEAD | bash scripts/review-checks.sh; echo rc=$?
review-checks: PASS (hardcoded-paths clean)
rc=0

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

…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 john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Reviewed at head 7419ff87. You asked not to rubber-stamp, so here's independent verification rather than a re-read of the write-up — cloned, checked out, ran everything myself.

Both behavior claims reproduce exactly.

$ printf "" | bash scripts/review-checks.sh; echo rc=$?
review-checks: ERROR — empty diff; nothing was scanned, so this is NOT a pass.
rc=2
$ printf "" | bash scripts/review-checks.sh --allow-empty; echo rc=$?
review-checks: empty diff — nothing to check (--allow-empty).
rc=0

A real diff still flags/passes correctly (ran your hardcoded-path fixture, got rc=1 as expected).

Mutation proof, reproduced myself (not just re-read): swapped origin/main's pre-fix runner back in under this PR's new test file → exactly the 4 failures you show, same test names. Restored → clean 76/76. That's a real before/after delta, not a happy-path-only test.

review-preflight.py note, checked against main: confirmed main says the runner "compares the WORKING TREE" — objectively wrong (it reads stdin, always did). Your branch replaces it with the accurate stdin description. Good catch independent of the main fix; that stale sentence really does point a reader at the trap.

ci.yml doc-only change confirmed — a two-line comment addition next to the existing #2281 note, no logic touched there.

All four suites green at this head on my machine: review-checks.test.sh (76/76), review-checks-py.test.py (244/244), review-checks-clt-shims.test.sh (15/15), review-preflight.test.py (10/10, OK).

On the two judgment calls you flagged

Empty 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. --allow-empty is the right escape hatch for a caller that legitimately expects a possibly-empty diff — it keeps the failure mode opt-in rather than surprising.

Exit code 2 vs 1: 2 is correct, and it's not just internally consistent — it's already an established precedent I verified independently. skills/claude-codex/scripts/review-pr.sh:39 exits 2 on an empty gh pr diff, with tests/review-pr.test.sh pinning that contract. So this PR isn't inventing a convention, it's aligning with one that already exists elsewhere in the repo for the identical condition (checked this myself, not taken from the PR body).

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.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

No lines with coverage information in this diff.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head 7419ff87.

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 --diff files fail closed with rc=2, while --allow-empty is the explicit escape hatch for callers that truly accept an empty diff. The CI comment is aligned with that contract and review-preflight.py no longer claims the no-stdin path scans the working tree.

Focused checks:

  • bash tests/review-checks.test.sh passed: 76 checks.
  • python3 tests/review-checks-py.test.py passed: 244 checks.
  • bash tests/review-checks-clt-shims.test.sh passed: 15 checks.
  • python3 tests/review-preflight.test.py passed: 10 tests.
  • bash scripts/review-checks.sh --diff /private/tmp/sutando-pr2818-0007.diff passed.
  • git diff --check origin/main...HEAD passed.

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.

@sonichi

sonichi commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Reviewed at head, and I ran it rather than reading it. No blockers.

Behavior probe — the three exit codes the PR claims:

printf '' | review-checks.sh                 -> rc=2   (want 2)
printf '' | review-checks.sh --allow-empty   -> rc=0   (want 0)
real flagged diff                            -> rc=1   (want 1)

That third line is the one I care about. A "fail closed on empty" change can
trivially break detection and every empty-input test would still pass — the
suite would be green about a scanner that no longer scans. rc=1 on a genuine
/Users/ hit is the positive control that rules it out.

Suites at head: tests/review-checks.test.sh PASS (76 checks),
tests/review-checks-clt-shims.test.sh 15/15.

Two things I checked because they are the usual casualties of a header edit:

  • -h|--help moved sed -n '2,16p' → '2,22p'. At this head line 22 is the
    last header line and line 23 is set -u, so --help still prints exactly the
    header and no code. Correct, and easy to get off by one.
  • Caller census for the new rc=2: the only non-test invocations are ci.yml
    (in this diff) and review-checks-clt-shims.test.sh:54, which always passes
    --diff with a written file. Nothing in the repo calls the runner with no
    input and relies on 0, so the fail-closed flip breaks no existing caller.

What makes this the right fix rather than a stricter one: the old line
conflated nothing was scanned with nothing was found, and those differ only
in a place no caller could see — the exit status was identical. --allow-empty
keeps the escape hatch explicit at the call site instead of implicit in the
runner, so a caller that genuinely may pass an empty diff has to say so. That
is the distinction worth having.

The test-harness change is also worth noting: check() growing a numeric
want means the empty-input cases assert rc=2 specifically, not merely
"non-zero". A rc!=0 assertion would have been satisfied by the usage-error
path too, and would not have caught a regression that turned the new branch
into an arg-parse failure.

One observation, not a request: ci.yml gets a comment but no --allow-empty,
so CI fails closed on a genuinely empty PR diff. I think that is intended and
correct — an empty PR diff is degenerate — but flagging it since the comment
describes the consequence and someone may later read the red as a CI bug.

(Not recording a formal approval: the gh credential on this node is the
owner's identity, so APPROVE is left to a human or an explicitly authorised lane.)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Fixed in 67aad71 — both blocks collapsed to two lines, no behavioral change.

scripts/review-checks.sh (4 lines → 2):

-# Exit: 0 = clean; 1 = a check flagged something; 2 = usage error, an EMPTY input
-#       (nothing was scanned), or the scanner failed to launch/run — all
-#       fail-closed, and none of them print PASS. --allow-empty makes an empty
-#       input exit 0 for callers that legitimately scan a possibly-empty diff.
+# 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.

The header is two lines shorter, so --help had to follow it: sed -n '2,22p' → sed -n '2,20p'. Verified the help text still ends at the header and does not leak set -u:

$ bash scripts/review-checks.sh --help | tail -3
#
# 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.

tests/review-checks.test.sh (3 lines → 2, keeping the section rule):

 # --- empty input is "nothing was SCANNED", not "nothing was FOUND" -----------
-# Exit 0 here made a no-op invocation indistinguishable from a clean gate: the
-# runner is routinely called by agents/wrappers that read only the exit status.
+# Exit 0 let a no-op read as a clean gate to callers that check only the status.

All four suites you named, before (at 7419ff8) and after (at 67aad71) — identical counts:

suite 7419ff8 67aad71
bash tests/review-checks.test.sh PASS — review-checks runner (76 checks) PASS — review-checks runner (76 checks)
bash tests/review-checks-clt-shims.test.sh passed=15 failed=0 passed=15 failed=0
python3 tests/review-checks-py.test.py PASS — review-checks.py internals (244 checks) PASS — review-checks.py internals (244 checks)
python3 tests/review-preflight.test.py Ran 10 tests / OK Ran 10 tests / OK

And the runner over its own diff:

$ git diff HEAD~1 | bash scripts/review-checks.sh; echo "rc=$?"
review-checks: PASS (hardcoded-paths clean)
rc=0

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head 67aad713.

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 --help slice was recalibrated so it still prints the header only. The behavior remains fail-closed for empty stdin and empty --diff files, with --allow-empty as the explicit opt-in.

Focused checks:

  • bash tests/review-checks.test.sh passed: 76 checks.
  • python3 tests/review-checks-py.test.py passed: 244 checks.
  • bash tests/review-checks-clt-shims.test.sh passed: 15 checks.
  • python3 tests/review-preflight.test.py passed: 10 tests.
  • bash scripts/review-checks.sh --diff /private/tmp/sutando-pr2818-0037.diff passed.
  • git diff --check origin/main...HEAD passed.

Hosted diff coverage and static gates are green; clean-install was still running at review time.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@sonichi

sonichi commented Aug 12, 2026

Copy link
Copy Markdown
Owner

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 scripts/review-checks.sh and ran it):

empty stdin              rc=2   ✓
whitespace-only stdin    rc=2   ✓
empty + --allow-empty    rc=0   ✓

The test fails in the broken state, which is what makes it worth having: check "empty stdin fails closed" 2 $'' compares against want=2, and the pre-fix runner exits 0 — so it goes red on the old code rather than passing vacuously.

Caller census — nothing breaks. Two real callers on main:

  • .github/workflows/ci.yml:73 passes --diff /tmp/review-pr.diff under set -euo pipefail. rc=2 now aborts the step, which is the intended fail-closed and matches the comment you added.
  • skills/claude-codex/scripts/review-pr.sh:52 — MECH="$(printf '%s' "$DIFF" | bash "$CHECKS_SH" 2>&1 || true)". No breakage: || true absorbs rc=2, and because it captures 2>&1 your new two-line ERROR text lands in the printed "Mechanical checks" block, so the failure gets louder there rather than silent.

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 review-checks.py/REVIEW.md resolution fails there and it correctly fails closed. Your diff touches only the empty-input branch (plus arg parsing, help range, comments), so unchanged behaviour on real diffs follows from the diff itself; I am not reporting those two numbers as evidence of anything.

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

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

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. AGENTS.md is explicit that APPROVE / REQUEST_CHANGES is a formal review action (gh pr review), not a comment — and ruleset 19110427 on main sets required_approving_review_count: 2, so the count is what gates.

Where the PR actually stands at 67aad713:

$ gh api repos/sonichi/sutando/commits/67aad713.../check-runs --jq '.check_runs[]|"\(.conclusion//"PENDING")  \(.name)"' | sort | uniq -c
   1 success  tsc + tests (clean install)
   1 success  diff coverage >= 95% (python)
   ... 15 checks, all success, none pending
$ python3 scripts/merge-gate.py --repo sonichi/sutando --author qingyun-wu
  ··· #2818 [MINE] 1 current approval(s); GitHub says REVIEW_REQUIRED — queued on sonichi

So: green, one head-timed approval (john-the-dev re-approved at 67aad713 specifically to make it current), and one short. If your read at head is the approval it sounds like, recording it as a formal review is what moves this to the owner's merge decision.

Merging stays the owner's call either way — I'm only reporting the gate state, not asking for a merge.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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-empty has 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.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head 09bc1310928e3b41ba932bd72f362638f4428d2f.

Approval signal for the refreshed head. The review-checks path still fails closed on empty/unscanned diffs, preserves the explicit --allow-empty escape hatch, and keeps the Python internals fail-closed around selected candidate paths; no blocking findings.

Verification:

  • git diff --check origin/main...HEAD
  • bash tests/review-checks.test.sh - 76 checks pass
  • python3 tests/review-checks-py.test.py - 244 checks pass
  • Hosted broad checks were still in progress when viewed.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu
qingyun-wu requested a review from yixuan-ag2 August 13, 2026 08:39

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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. Your ci.yml note 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 empty gh pr diff is 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.md describe 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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

I re-reviewed exact merge head aa8a2b99ab3f2f166fbb5864d20f356c8e0a5c4c.

This update only merges current main. The fail-closed empty-input topic patch retains the same stable patch ID before and after the merge (9e4a9b736563e6dcfc5b4999959d2409b42156a1), so there is no conflict-resolution drift.

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.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit 039c9bf into main Aug 14, 2026
16 checks passed
@john-the-dev
john-the-dev deleted the fix/review-checks-empty-input-fail-closed branch August 14, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants