Files
ersatztv/scripts/check-review-verdict.sh
T
timothyandClaude Fable 5.1 a7d91bf15a
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only
`docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites
from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep
over every tracked file outside the detector, for the detector's own phrasings plus the attribution
and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass
for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer
proved") added residuals in the same files. Every site was classified with #812's three
dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site
manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and
traps stay; the attribution of who found them and the round in which they were found go.

The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless
of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus
`scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as
the records' own exemption, and what keeps the record's depth measurement true), and `--all`
lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812
argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff`
sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where
narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over
the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same
measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS
matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill
the withdrawn parity test ran on. The population oracle is restated over segments with the new
arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture
witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a
by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process
arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures
exemption, list only markdown, drop the symlink filter, test the mode per row instead of per
path) each
redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++`
filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no
notice — fixed, with a positive control and its own mutant.

Code is unchanged by construction, measured per file type against `origin/main`: Python modules
are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs
(string literals) of three test modules; workflows differ only in `#` lines inside `run:` block
scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated
exceptions: the detector and its test, 26 vitest titles that carried review-round or severity
labels or a reviewer attribution (call sites whose title changed — every changed title line
walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two
registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed.
Web: lint, typecheck, 1319 tests green. Closes #876.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
2026-09-03 20:51:39 +02:00

296 lines
19 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 a NEGATIVE word -> block
# positive a verdict on the CURRENT head is a POSITIVE word -> allow
# (the words themselves: scripts/lib/review-verdict-vocabulary.sh, shared with the
# write side so the two cannot drift — ersatztv#788)
# 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
# --- The shared verdict vocabulary (ersatztv#788). ----------------------------------------------
# This script (the READ side) and scripts/post-review-verdict.sh (the WRITE side) used to carry two
# hand-written copies of the word list — `POS_RE`/`NEG_RE` here, `case` arms there — held together
# by nothing but a comment. They now derive from one declaration, so a word added on one side
# cannot fail to reach the other.
#
# Resolved relative to THIS FILE, not the caller's cwd: the merge-consent hook invokes this script
# by absolute path (`$repo_root/scripts/check-review-verdict.sh`, where `$repo_root` is derived from
# that hook's own `${BASH_SOURCE[0]}`) from whatever directory the tool call happened in, so a
# cwd-relative lookup would find the vocabulary only by luck. That path was
# `${CLAUDE_PROJECT_DIR:-.}/...` until ersatztv#858 — an env var choosing which checkout's copy of
# this classifier decides a merge — and the reasoning for the change lives beside the caller.
#
# Every failure below is exit 2, the script's declared input-error code, because callers MUST fail
# closed on it (see the header). A classifier that cannot load its vocabulary does not know what a
# verdict word is; the one thing it must never do is fall through to `absent`, which the hook reads
# as "convention not adopted" and downgrades into an `ask`.
#
# THE GATE IS THE SENTINEL, NOT THIS `if`. Stated because the obvious reading is wrong: under
# `set -u` an unbound-variable error inside a function aborts the function and NOT the script, and
# in an `if ! validate` form neither branch is then taken, so the `exit 2` is skipped and execution
# continues. That is a measured fail-open, not a hypothetical — see the sentinel comment in the
# library. `etv_verdict_alternation` refusing without the sentinel is what actually holds.
_here=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || {
printf 'check-review-verdict: could not resolve this script own directory to locate the shared verdict vocabulary\n' >&2; exit 2; }
VOCABULARY_LIB="$_here/lib/review-verdict-vocabulary.sh"
if [ ! -r "$VOCABULARY_LIB" ]; then
printf 'check-review-verdict: the shared verdict vocabulary is missing or unreadable at %s\n' "$VOCABULARY_LIB" >&2; exit 2
fi
# PROBE-SOURCE IN A SUBSHELL FIRST, and require the library's end-of-file marker on stdout. A
# top-level `exit` in a sourced file terminates THIS script where the `source` stands, so no check
# written after it can run: the reader returned exit 0 with empty stdout, which violates its
# "exactly one classification word" contract and would read as success to any caller that checks
# only the exit code. The probe moves that failure into a subshell, where it is observable. It also
# covers a syntax error and a truncated file, since neither reaches the marker.
# shellcheck source=lib/review-verdict-vocabulary.sh
vocabulary_probe=$( . "$VOCABULARY_LIB" >/dev/null 2>&1 && etv_verdict_vocabulary_loaded 2>/dev/null ) || vocabulary_probe=''
if [ "$vocabulary_probe" != 'etv-verdict-vocabulary-loaded' ]; then
printf 'check-review-verdict: the shared verdict vocabulary at %s did not load to completion (syntax error, truncated, or it exits at top level)\n' "$VOCABULARY_LIB" >&2; exit 2
fi
# shellcheck source=lib/review-verdict-vocabulary.sh
if ! . "$VOCABULARY_LIB"; then
printf 'check-review-verdict: the shared verdict vocabulary at %s could not be sourced\n' "$VOCABULARY_LIB" >&2; exit 2
fi
# The return code is checked, but it is NOT what makes this safe — a `set -u` abort inside the
# function would skip both branches of this `if` and carry straight on. What makes it safe is that
# validation sets a sentinel on its last line and `etv_verdict_alternation` refuses without it, so a
# skipped validation cannot yield a pattern. Both are kept: this gives the accurate diagnostic.
if ! etv_verdict_vocabulary_validate; then
printf 'check-review-verdict: the shared verdict vocabulary at %s did not validate (see above)\n' "$VOCABULARY_LIB" >&2; exit 2
fi
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. Joining bodies with a literal sentinel line instead would let
# a comment containing that sentinel 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): <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]|$)'
# The WORD SET comes from the shared vocabulary; the GRAMMAR around it stays here (ersatztv#788).
# That split is the point: every false-open in this file's history was in the grammar — the column-0
# anchor, the whole-word boundary, the anchored `@ <sha>` field — and none of it belongs in a word
# list. `etv_verdict_alternation` emits only the alternation BODY (`word|word|word`); the anchors
# and the `([[:space:]@]|$)` boundary below are unchanged.
#
# Interpolating into an ERE is safe because every word is restricted to `[a-z][a-z-]*`, so no
# character reaching this regex can be a metacharacter. Without that a `.*` in the positive list
# would make every verdict-shaped line classify `positive`, including an explicit BLOCKED.
#
# WHAT ENFORCES IT is `etv_verdict_alternation` refusing unless validation set its sentinel — NOT
# the `if ! etv_verdict_vocabulary_validate` above. Saying "validated above, therefore safe" was the
# false premise that shipped the hole: a `set -u` abort inside that function skips both branches of
# the `if`, and the words then reached this line unvalidated. The call above is kept for its
# diagnostic; the refusal below is the gate.
POS_ALTERNATION=$(etv_verdict_alternation positive) || {
printf 'check-review-verdict: could not build the positive verdict pattern from %s\n' "$VOCABULARY_LIB" >&2; exit 2; }
NEG_ALTERNATION=$(etv_verdict_alternation negative) || {
printf 'check-review-verdict: could not build the negative verdict pattern from %s\n' "$VOCABULARY_LIB" >&2; exit 2; }
POS_RE='^review-verdict:[[:space:]]*('"$POS_ALTERNATION"')([[:space:]@]|$)'
NEG_RE='^review-verdict:[[:space:]]*('"$NEG_ALTERNATION"')([[: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'