Files
ersatztv/scripts/lib/review-verdict-vocabulary.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

232 lines
12 KiB
Bash

# shellcheck shell=bash
# review-verdict-vocabulary.sh — THE H10 verdict vocabulary, declared ONCE (ersatztv#788).
#
# SOURCED, NEVER EXECUTED. It defines data plus pure functions and takes no action of its own, so it
# is deliberately not executable and carries no shebang.
#
# WHY THIS FILE EXISTS. The vocabulary used to live in two hand-written shell copies: the `case`
# arms of `scripts/post-review-verdict.sh` (the WRITE side, which turns a word into a commit-status
# state) and the `POS_RE`/`NEG_RE` regexes of `scripts/check-review-verdict.sh` (the READ side,
# which classifies a `Review-verdict:` comment for the merge-consent hook). A word the write side
# treats as positive but the read side does not sends the server-side status green while the hook
# still denies — an unexplained deny on a gate whose whole job is to be explicable.
#
# WHY IT IS NOT A TEST. ersatztv#774 tried to hold the two copies together with a parity test that
# extracted both vocabularies from their own shell source with regexes and compared them. Six
# successive fixes each met another shell construction that either escaped the extractor (`SHIP*)`, a
# glob in the arm label; an unquoted `SHIP-IT) state=success`) or reddened it on a correct tree (a
# `<word>) state=` inside a heredoc; a column-zero `esac` in a string truncating the scoped match).
# Each fix was locally correct and the sequence converged on nothing, because a regex over shell
# source is not a shell parser. It was withdrawn rather than patched a seventh time — see
# `docs/decisions/records/testing/guard-derives-population-from-source.md`, which carries it as the
# worked example of "a weak detector is itself the symptom-keyed mistake".
#
# So this file removes the duplication rather than detecting it. There is one list; both sides
# DERIVE from it. Do not reintroduce a text-comparison test alongside — the lesson of those six
# rounds is that the shape does not converge, and dedup by construction is what closes it.
#
# ADDING A WORD is a one-line edit to the list below, and it reaches both sides by construction.
# `scripts/tests/test_review_verdict_vocabulary.py` proves exactly that, by adding a word to a
# sandbox copy of THIS file and driving both real scripts.
# --- The vocabulary. Whitespace-separated, lower case. -------------------------------------------
# Positive: the merge may proceed -> commit status `success`.
# Negative: the merge stays blocked -> commit status `failure`.
# A word in NEITHER list is `unknown` on the read side and a hard refusal on the write side. That is
# deliberate and must stay: a token nobody recognises is surfaced for a human, never guessed at.
#
# DECLARED AS ARRAYS, not as whitespace-separated strings, and every expansion below is quoted
# (`"${arr[@]}"`). A string list has to be split by an UNQUOTED expansion, and an unquoted expansion
# also performs PATHNAME EXPANSION: with `.*` in the list the validator below reported on `.claude`,
# a file in the repo root, rather than on the word actually written. That makes validation depend on
# the working directory, and it fails OPEN in the case that matters — a `*` entry expands to the
# filenames around it, and any of those matching `[a-z][a-z-]*` (`scripts`, `docs`) would validate
# cleanly and enter the vocabulary as a real verdict word. Arrays remove the expansion rather than
# guarding it.
ETV_VERDICT_POSITIVE_WORDS=(mergeable approved lgtm)
ETV_VERDICT_NEGATIVE_WORDS=(blocked not-mergeable)
# --- The validation sentinel. -------------------------------------------------------------------
# Reset to 0 HERE, at load time, for two reasons. It defeats an inherited `ETV_VERDICT_VOCABULARY_OK=1`
# from the environment, and — the reason it exists — it makes "validated" a fact the DERIVED VIEWS
# can require, rather than something a caller is trusted to have checked.
#
# WHY A SENTINEL AND NOT A RETURN CODE. `etv_verdict_vocabulary_validate` used to be gated as
# `if ! etv_verdict_vocabulary_validate; then exit 2; fi`, and that is bypassable: under `set -u` an
# unbound-variable error inside a FUNCTION aborts the function but not the script, and in the
# `if ! f` form NEITHER branch is then taken — execution simply continues past the gate. Measured:
# with the negative list written as a scalar (`ETV_VERDICT_NEGATIVE_WORDS="blocked"`) and `.*` in the
# positive list, `check-review-verdict.sh` classified an explicit `BLOCKED @ <head>` as `positive`,
# exit 0, with only a stderr line the calling hook discards. That is the exact fail-open this whole
# file exists to make impossible.
#
# A control-flow gate can be skipped by an abort. A DATA dependency cannot: the words are now
# unobtainable unless validation ran all the way to its final line.
ETV_VERDICT_VOCABULARY_OK=0
# --- Validation. -------------------------------------------------------------------------------
# The read side interpolates these words into an EXTENDED REGULAR EXPRESSION. That makes the list an
# injection surface, and the failure direction is the dangerous one: a stray `.*` in the positive
# list would make POS_RE match every verdict-shaped line, so an explicit `BLOCKED` would classify as
# `positive` and the merge gate would grant consent it was never given. Restricting words to
# `[a-z][a-z-]*` means no character reaching the regex can be a metacharacter, which is a property of
# the character class rather than of anyone remembering to escape.
#
# Returns 0 when the vocabulary is usable, 1 otherwise (message on stderr). It never exits: the two
# consumers fail closed with DIFFERENT codes (`post` dies 1, `check` exits 2 so its callers treat it
# as an unreadable input), so the exit semantics belong to them, not here.
# True when $1 names a set variable carrying the ARRAY attribute. Checked before any `${#name[@]}`,
# because that expansion is nounset-safe ONLY for a declared-empty array: on an UNSET name or on a
# SCALAR it is a fatal unbound-variable error, which is precisely the abort that used to skip
# validation entirely. `declare -p` answers without expanding anything.
etv_verdict__is_array() {
local declaration flags
declaration=$(declare -p "$1" 2>/dev/null) || return 1
flags=${declaration#declare -}
flags=${flags%% *}
case "$flags" in
*a*) return 0 ;;
*) return 1 ;;
esac
}
etv_verdict_vocabulary_validate() {
local word seen=' ' name
ETV_VERDICT_VOCABULARY_OK=0
for name in ETV_VERDICT_POSITIVE_WORDS ETV_VERDICT_NEGATIVE_WORDS; do
if ! etv_verdict__is_array "$name"; then
printf 'review-verdict vocabulary: %s is not an array — declare it as %s=(word word), not as a string\n' "$name" "$name" >&2
return 1
fi
done
if [ "${#ETV_VERDICT_POSITIVE_WORDS[@]}" -eq 0 ] || [ "${#ETV_VERDICT_NEGATIVE_WORDS[@]}" -eq 0 ]; then
printf 'review-verdict vocabulary: the positive and negative lists must both be non-empty\n' >&2
return 1
fi
for word in "${ETV_VERDICT_POSITIVE_WORDS[@]}" "${ETV_VERDICT_NEGATIVE_WORDS[@]}"; do
# Rejects upper case, digits, whitespace and every regex metacharacter, plus a leading or
# trailing hyphen (which would build an alternation branch that reads oddly and matches nothing
# useful). `-` is last in the bracket expression, where it is literal.
case "$word" in
'' | *[!a-z-]* | -* | *-)
printf 'review-verdict vocabulary: %s is not a usable verdict word (allowed: [a-z] and internal -)\n' "$word" >&2
return 1
;;
esac
# A word in BOTH lists would classify as whichever side is consulted first — an ambiguity that
# would silently pick a side. Reject it here instead.
case "$seen" in
*" $word "*)
printf 'review-verdict vocabulary: %s appears twice; a word must be positive or negative, not both\n' "$word" >&2
return 1
;;
esac
seen="$seen$word "
done
# The ONLY assignment to 1 in this file, and it is the last statement of the successful path. Any
# earlier `return 1`, and any abort part-way through, leaves the sentinel at 0.
ETV_VERDICT_VOCABULARY_OK=1
return 0
}
# --- Derived view 1: classify one token (the WRITE side). ---------------------------------------
# Prints `positive` or `negative` and returns 0; returns 1 for a word in neither list, printing
# nothing. Case-insensitive, so the caller may pass the operator's raw argument.
etv_verdict_class() {
local token word
if [ "${ETV_VERDICT_VOCABULARY_OK:-0}" != 1 ]; then
printf 'review-verdict vocabulary: not validated — refusing to hand out verdict words\n' >&2
return 1
fi
token=$(printf '%s' "${1-}" | tr '[:upper:]' '[:lower:]')
for word in ${ETV_VERDICT_POSITIVE_WORDS[@]+"${ETV_VERDICT_POSITIVE_WORDS[@]}"}; do
if [ "$word" = "$token" ]; then
printf 'positive\n'
return 0
fi
done
for word in ${ETV_VERDICT_NEGATIVE_WORDS[@]+"${ETV_VERDICT_NEGATIVE_WORDS[@]}"}; do
if [ "$word" = "$token" ]; then
printf 'negative\n'
return 0
fi
done
return 1
}
# --- Derived view 2: an ERE alternation body (the READ side). -----------------------------------
# `etv_verdict_alternation positive` -> `mergeable|approved|lgtm`. The caller wraps it in its own
# anchors and boundary, so this deliberately emits no parentheses: the grammar around the token
# belongs to the classifier, only the WORD SET belongs here.
etv_verdict_alternation() {
local alternation='' word
local words=()
if [ "${ETV_VERDICT_VOCABULARY_OK:-0}" != 1 ]; then
printf 'review-verdict vocabulary: not validated — refusing to hand out verdict words\n' >&2
return 1
fi
case "${1-}" in
positive) words=(${ETV_VERDICT_POSITIVE_WORDS[@]+"${ETV_VERDICT_POSITIVE_WORDS[@]}"}) ;;
negative) words=(${ETV_VERDICT_NEGATIVE_WORDS[@]+"${ETV_VERDICT_NEGATIVE_WORDS[@]}"}) ;;
*)
printf 'review-verdict vocabulary: etv_verdict_alternation needs positive|negative, got %s\n' "${1-}" >&2
return 1
;;
esac
for word in ${words[@]+"${words[@]}"}; do
if [ -z "$alternation" ]; then alternation="$word"; else alternation="$alternation|$word"; fi
done
[ -n "$alternation" ] || return 1
printf '%s\n' "$alternation"
}
# --- Derived view 3: the operator-facing list. --------------------------------------------------
# `MERGEABLE, APPROVED, LGTM, BLOCKED or NOT-MERGEABLE`. Derived rather than written out, because
# the "unknown verdict" error message naming a stale set is how an operator learns the wrong
# vocabulary — the same drift this file exists to remove, one layer out.
etv_verdict_words_display() {
local word index=0 total out=''
if [ "${ETV_VERDICT_VOCABULARY_OK:-0}" != 1 ]; then
printf 'review-verdict vocabulary: not validated — refusing to hand out verdict words\n' >&2
return 1
fi
set -- ${ETV_VERDICT_POSITIVE_WORDS[@]+"${ETV_VERDICT_POSITIVE_WORDS[@]}"} \
${ETV_VERDICT_NEGATIVE_WORDS[@]+"${ETV_VERDICT_NEGATIVE_WORDS[@]}"}
total=$#
for word in "$@"; do
index=$((index + 1))
word=$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')
if [ "$index" -eq 1 ]; then
out="$word"
elif [ "$index" -eq "$total" ]; then
out="$out or $word"
else
out="$out, $word"
fi
done
printf '%s\n' "$out"
}
# --- Reached-the-end marker. --------------------------------------------------------------------
# A top-level `exit` in a SOURCED file terminates the sourcing script where it stands, so no check
# placed after the `source` can ever run — the reader returned exit 0 with empty stdout, violating
# its own "exactly one classification word" contract. Both consumers therefore source this file in a
# SUBSHELL first and require the marker below on stdout; a file that exits early never prints it.
# Defining a function is not enough (a truncated file can define every function and still stop
# short), so this is the LAST line and it must stay last.
etv_verdict_vocabulary_loaded() { printf 'etv-verdict-vocabulary-loaded\n'; }