Files
ersatztv/scripts/check-review-verdict.sh
T
timothyandClaude Opus 5 7265fba36d fix(647): the H10 verdict classifier was inert on jq 1.6 — the runner's version
Turning on the scripts/tests suite in CI immediately paid for itself: measured on
origin/main, 61 of 178 tests FAIL under jq 1.6, which is what the CI runner ships. They
pass on a dev Mac's jq 1.8.2, which is why this was invisible — and the suite has never
run anywhere else, which is exactly #631's thesis.

Two defects in scripts/check-review-verdict.sh (from #629, the single source of truth
for H10 verdict classification):

1. `contains("<NUL>")` is TRUE FOR EVERY STRING on jq 1.6 — the escape truncates the
   literal to the empty string, and every string contains "". So the body guard errored
   "NUL in body" on every comment and the H10 grammar was entirely inert on the runner.
   Verified against both binaries: 1.6 says true for "hello", 1.7+ says false. Replaced
   with `(explode | index(0)) != null`, which involves no regex engine and agrees on
   both.

2. A parse error was indistinguishable from "no output". The script used jq's exit code
   to separate malformed input from a legitimately empty comment list, treating 4 as
   benign — but jq >= 1.7 exits 5 on a parse error while 1.6 exits 4, the same code both
   use for "filter produced no output". On 1.6 a garbage API response therefore returned
   `absent` instead of an input error. Fixed with an explicit `jq empty` pre-check, which
   is non-zero iff the input does not parse regardless of output volume.

Severity: fail-closed, not exploitable. The classifier is only invoked from the
merge-consent hook, which runs on the dev machine (jq 1.8.2), so the live gate is
unaffected. The cost is that #629's hardening was inert on the runner and would have
stayed invisible.

198 tests now pass under BOTH jq 1.8.2 and jq 1.6 (was 138/60 split under 1.6).

This is the third distinct jq-1.6 divergence found in this codebase today (the first was
#643's `jq -e` on empty input). The rule: a shell gate's behaviour is a function of its
interpreter's version — test against the version CI actually runs, or pin it.

Refs #647, #631

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:20 +02:00

222 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Classify a PR's `Review-verdict:` comments against its CURRENT head sha (ersatztv#303 H10, #629).
#
# Extracted from `.claude/hooks/pretooluse-merge-consent.sh` so the H10 grammar can be TESTED. While it
# lived inline it had no tests, and four false-opens survived in it — each of which made an unreviewed
# or explicitly-blocked head read as approved (#629).
#
# Usage: check-review-verdict.sh --head <sha> < comments.json
# stdin : the Gitea `issues/{index}/comments` JSON array (objects carrying `.body`).
# stdout : exactly one classification word (below).
# exit : 0 on a successful classification; 2 on a usage/input error (callers MUST fail closed —
# an unreadable input is never a pass).
#
# Classifications, in the order they are decided:
# negative a verdict on the CURRENT head is BLOCKED/NOT-MERGEABLE -> block
# positive a verdict on the CURRENT head is MERGEABLE/APPROVED/LGTM -> allow
# stale verdict(s) exist but reference only OLDER commits -> block (the #242 case)
# unknown a verdict line uses a token in neither vocabulary -> undecidable, surface it
# no-sha a `Review-verdict:` marker carries no `@ <sha>` field -> undecidable
# absent no `Review-verdict:` marker anywhere -> undecidable (not adopted)
#
# THE GRAMMAR (deliberately strict — every relaxation here has been a false-open):
#
# ^review-verdict: [space]* <TOKEN> [space]* @ [space]* <7-40 hex> (marker at COLUMN 0)
#
# - **Marker at COLUMN 0** — no leading whitespace (#629). This rejects a mid-sentence quote
# ("please post: Review-verdict: MERGEABLE @ …"), and it also rejects markdown's *indented* code
# blocks (4 spaces or a tab) and anything nested in a list. Indented code is a second code-block
# form the fence stripper does not cover, and patching each form in turn is how three rounds of
# false-opens happened; requiring column 0 removes the ambiguity rather than enumerating it.
# The cost is that a verdict indented under a list item is ignored — it classifies `absent`, which
# asks a human. Erring toward ignoring is the safe direction for a gate.
# - **Fenced code blocks are stripped first** (#629). The line-start anchor alone does not save us:
# inside a ``` block the marker IS at line start, so documentation showing the convention counted
# as a real verdict. Fence state is tracked PER COMMENT BODY so an unclosed fence in one comment
# cannot swallow or expose another. (Blockquotes need no special handling — a `>` prefix already
# fails the anchor.)
# - **The token must be a whole word** (#629). The old test prefix-matched, so `MERGEABLE-LATER`,
# `APPROVED-PENDING-QA` and `LGTMish` all graded as positive. A token in neither vocabulary is
# `unknown`, NOT positive and NOT a block — it is surfaced for a human rather than guessed at.
# - **The sha is read from the verdict's OWN `@ <sha>` field** — the one immediately after the token —
# never "the first `@<hex>` anywhere on the line" (#629). That older rule let a markdown link supply
# the sha: `Review-verdict: MERGEABLE [x](https://e/@0123456)` graded against the URL. Anchoring the
# field also makes multi-`@` lines unambiguous, so a verdict naming one sha cannot be re-read as a
# verdict for another.
# - **Prefix, not substring**: head must BEGIN WITH the token, token >= 7 chars (git short-sha
# semantics), so an older sha that merely contains the head prefix does not match.
#
# "negative wins over positive on the same head" is deliberate: a later BLOCKED retracts an earlier
# MERGEABLE. Staleness is symmetric on purpose — a negative for an OLDER commit is stale exactly like
# a positive for one, and must not override a fresh head-positive, or a pre-fix `BLOCKED @ oldsha`
# would block forever even after the fix changes the sha and earns a fresh verdict.
set -uo pipefail
head=""
while [ $# -gt 0 ]; do
case "$1" in
--head) head="${2:-}"; shift 2 ;;
*) printf 'check-review-verdict: unknown argument: %s\n' "$1" >&2; exit 2 ;;
esac
done
[ -n "$head" ] || { printf 'check-review-verdict: --head <sha> is required\n' >&2; exit 2; }
head=$(printf '%s' "$head" | tr 'A-F' 'a-f')
case "$head" in
*[!0-9a-f]*|"") printf 'check-review-verdict: --head is not a hex sha: %s\n' "$head" >&2; exit 2 ;;
esac
comments=$(cat)
[ -n "$comments" ] || { printf 'check-review-verdict: empty comments payload on stdin\n' >&2; exit 2; }
# `jq -e` so malformed JSON is an input error (exit 2), never a silent "absent" — which would read as
# "convention not adopted" and downgrade a hard block into an ask.
#
# Each body is emitted as a JSON STRING on its own line (newlines escaped by JSON), so comment
# boundaries are carried out-of-band. An earlier version joined bodies with a literal sentinel line;
# a comment containing that sentinel could forge a boundary, reset fence state mid-body, and expose a
# verdict that was still inside an unclosed fence. In-band delimiters are forgeable by whoever writes
# the data — and here that is anyone who can comment on the PR.
# The shape is asserted IN jq so a payload that isn't an array of comment objects with STRING bodies
# is an input error (exit 2), not a silent `absent`. An object-valued `.body` used to reach the
# scanner and simply match nothing — a malformed payload reading as "no verdict posted" is a
# fail-OPEN on a gate whose whole job is to withhold approval.
# A body containing a NUL is rejected outright: bash strips NULs in command substitution, so
# NOTE the NUL test is `explode | index(0)`, NOT `contains("\u0000")` (ersatztv#647). On jq 1.6 the
# escape truncates the literal to the EMPTY string, and every string contains "" — so that form
# returns true for ALL input, making this guard reject every comment body as malformed. Verified
# against both binaries: 1.6 says true for "hello", 1.7+ says false. The CI runner ships jq 1.6, so
# the whole verdict classifier was inert there. `explode | index(0)` agrees on both.
#
# `Review<NUL>-verdict: MERGEABLE @ <head>` would arrive at the matcher as a valid verdict line —
# text that is not a verdict silently becoming one.
# PARSE CHECK FIRST, separately, because jq's exit codes are not portable enough to distinguish
# "malformed input" from "valid input, no output" (ersatztv#647): jq >= 1.7 exits 5 on a parse error
# while jq 1.6 exits 4 — the SAME code both versions use for "filter produced no output", which is
# the legitimate empty-comment-list case. So on jq 1.6 the check below could not tell a garbage API
# response from "no comments yet", and silently returned `absent` where it should have raised an
# input error. `jq empty` separates the two on every version: non-zero iff the input does not parse,
# regardless of how much output the filter would produce.
if ! printf '%s' "$comments" | jq empty >/dev/null 2>&1; then
printf 'check-review-verdict: stdin is not valid JSON\n' >&2
exit 2
fi
encoded=$(printf '%s' "$comments" | jq -ce '
if type != "array" then error("not an array") else .[] end
| (.body // "")
| if type != "string" then error("non-string body")
elif (explode | index(0)) != null then error("NUL in body")
else . end' 2>/dev/null)
jq_rc=$?
# `jq -e` exits 4 when a filter produced NO output — which is exactly the legitimate empty-comment-list
# case, not a malformed payload. Treating it as an error turned "no comments yet" into an input error,
# and callers fail closed on those, so an unremarkable new PR would have read as unclassifiable.
if [ "$jq_rc" -ne 0 ] && [ "$jq_rc" -ne 4 ]; then
printf 'check-review-verdict: stdin is not a JSON array of comment objects with string bodies\n' >&2
exit 2
fi
# Strip fenced code blocks per body, so fence state cannot leak between comments. Both fence markers
# markdown accepts are honoured: ``` and ~~~ (a verdict inside a `~~~` block was still counted).
#
# Fed by a PIPE, not a here-document. A here-doc makes bash materialise a temp file, and when that
# fails (read-only or restricted TMPDIR) the loop silently reads nothing — the classifier returns
# `absent` and a real BLOCKED verdict disappears. Gate failures must never land on the permissive
# side, and "the environment could not supply a temp file" is not evidence that a PR was approved.
# The loop runs in a subshell, so its result is captured through stdout rather than a variable.
verdicts=$(printf '%s\n' "$encoded" | while IFS= read -r encoded_body; do
[ -n "$encoded_body" ] || continue
# A body that fails to decode is fatal, not skippable — skipping one could drop the only BLOCKED
# verdict on the PR. 3 is distinct from the exit-2 paths above so the caller sees where it broke.
body=$(printf '%s' "$encoded_body" | jq -r '.' 2>/dev/null) || exit 3
# Fence tracking follows markdown: a fence opened with N markers is closed only by N-or-more of the
# SAME character. A naive "any line starting with 3 toggles" exits a ```` block at the first ```
# LINE INSIDE IT — which is legitimate content — and the verdict below it then counts as real.
# A shorter or different marker while a fence is open is content, so it neither closes nor prints.
outside=$(printf '%s\n' "$body" | awk '
{
# Raw HTML blocks are the third code-block form (#629 round 5): <pre>, <code> and HTML
# comments all render their contents literally, so a verdict inside one is an example, not an
# approval. Tracked as a simple depth/marker count rather than parsed — the direction of error
# is to strip MORE, which can only ever withhold approval.
low = tolower($0)
if (low ~ /<!--/) { html = 1 }
if (low ~ /<(pre|code)[ >]/ || low ~ /<(pre|code)>/) { html = 1 }
if (html) {
closed = 0
if (low ~ /-->/) { closed = 1 }
if (low ~ /<\/(pre|code)>/) { closed = 1 }
if (closed) { html = 0 }
next
}
if (match($0, /^[[:space:]]*(`{3,}|~{3,})/)) {
m = substr($0, RSTART, RLENGTH); gsub(/[[:space:]]/, "", m)
ch = substr(m, 1, 1); len = length(m)
if (!fence) { fence = 1; fch = ch; flen = len; next }
else if (ch == fch && len >= flen) { fence = 0; next }
}
if (!fence) print
}') || exit 4
# `grep` exits 1 for "no match" (normal) and >=2 for a real error. `|| true` flattened both into
# success, so a failing reader silently produced no verdicts — `absent` — and dropped a real
# BLOCKED verdict. Only "no match" may be tolerated.
found=$(printf '%s\n' "$outside" | grep -iE '^review-verdict:')
grep_rc=$?
[ "$grep_rc" -le 1 ] || exit 5
# An `if`, not `[ ... ] && printf`: the latter is the loop body's last command, so a final comment
# with no verdict would leave the SUBSHELL exiting 1 and the rc check below would report a reader
# failure on a perfectly ordinary PR.
if [ "$grep_rc" -eq 0 ]; then printf '%s\n' "$found"; fi
done)
verdict_rc=$?
if [ "$verdict_rc" -ne 0 ]; then
printf 'check-review-verdict: failed to read comment bodies (rc=%s)\n' "$verdict_rc" >&2; exit 2
fi
[ -n "$(printf '%s' "$verdicts" | tr -d '[:space:]')" ] || { printf 'absent\n'; exit 0; }
# The verdict field, anchored: token then its own `@ <sha>`. Two greps rather than a capture group,
# because BSD/macOS grep has no -P and `sed -E` backreference portability is worse than this.
# The hex run is matched WHOLE (`+`) and must end at a non-alphanumeric boundary or end-of-line, then
# its length is checked separately. Matching `{7,40}` directly had no right boundary, so an over-long
# or malformed token was silently TRUNCATED into a valid-looking one: `@ <40-hex-head><more hex>` and
# `@ <40-hex-head>ZZZ` both matched their first 40 chars and graded as a verdict for head.
FIELD_RE='^review-verdict:[[:space:]]*[A-Za-z][A-Za-z-]*[[:space:]]*@[[:space:]]*[0-9a-fA-F]+([^0-9a-zA-Z]|$)'
POS_RE='^review-verdict:[[:space:]]*(mergeable|approved|lgtm)([[:space:]@]|$)'
NEG_RE='^review-verdict:[[:space:]]*(blocked|not-mergeable)([[:space:]@]|$)'
head_pos=0; head_neg=0; stale=0; unknown=0
while IFS= read -r line; do
[ -n "$line" ] || continue
is_pos=0; is_neg=0
printf '%s' "$line" | grep -iqE "$POS_RE" && is_pos=1
printf '%s' "$line" | grep -iqE "$NEG_RE" && is_neg=1
if [ "$is_pos" = 0 ] && [ "$is_neg" = 0 ]; then
unknown=1 # a verdict-shaped line whose token is in neither vocabulary — never guess
continue
fi
# The sha from THIS line's own field. Take the anchored field, then its LAST hex run — the sha sits
# after the token, and the field ends at the boundary, so the last run is always the candidate.
field=$(printf '%s' "$line" | grep -ioE "$FIELD_RE" | head -1 || true)
ref=$(printf '%s' "$field" | grep -oE '[0-9a-fA-F]+' | tail -1 | tr 'A-F' 'a-f' || true)
# Length is validated HERE rather than in the regex, so an out-of-range token is rejected outright
# instead of being truncated to a passing prefix.
case "${#ref}" in
7|8|9|1[0-9]|2[0-9]|3[0-9]|40) : ;;
*) ref="" ;;
esac
[ -n "$ref" ] || continue # token recognized but no valid `@ <sha>` field -> falls through to `no-sha`
case "$head" in
"$ref"*) if [ "$is_pos" = 1 ]; then head_pos=1; else head_neg=1; fi ;;
*) stale=1 ;;
esac
done < <(printf '%s\n' "$verdicts")
if [ "$head_neg" = 1 ]; then printf 'negative\n'; exit 0; fi
if [ "$head_pos" = 1 ]; then printf 'positive\n'; exit 0; fi
if [ "$stale" = 1 ]; then printf 'stale\n'; exit 0; fi
if [ "$unknown" = 1 ]; then printf 'unknown\n'; exit 0; fi
printf 'no-sha\n'