Compare commits

..
Author SHA1 Message Date
renovate 74cf01b3e8 chore(deps): update dependency system.commandline to 2.0.11
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 39s
review-verdict/h10 Exempt: authored by the 'renovate' bot account, touches no protected path, and changes only dependency manifests
Review verdict / Set review-verdict status (pull_request_target) Successful in 38s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / Script tests (pytest) (pull_request) Successful in 3m45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
2026-08-17 03:03:16 +00:00
92 changed files with 1198 additions and 10917 deletions
+7 -255
View File
@@ -8,12 +8,8 @@
# LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode:
# "re-review the fix commit, not just the initial PR diff").
#
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. The window is SMALL for an
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and this comment used to say
# "sound", which is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
# performs the merge, so a push can still land in between. The merge API accepts an optional
# `head_commit_id` that would make that call a true compare-and-set; a PreToolUse hook cannot add an
# argument, only refuse without one. With merge_when_checks_succeed, Gitea merges
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. That is sound for an
# immediate merge and UNSOUND for a scheduled one: with merge_when_checks_succeed, Gitea merges
# later, against whatever head is green then (ersatztv#622). So the sha-bound half of H10 is
# enforced by the SERVER, not here — `review-verdict/h10` is a required status check on `main`,
# written per-sha by scripts/post-review-verdict.sh, and a new commit cannot inherit it. This hook
@@ -179,39 +175,10 @@ fi
# after which a later, successful status read could still auto-grant. A transient failure would then
# have produced a "merge gate: satisfied" message for a comparison that never happened. Every
# unreadable input here therefore falls through to a human (`ask`), never to silence.
# RE-READ THE BASE HERE, ONCE, FOR EVERY PATH BELOW (ersatztv#778).
#
# "Below" is literal, and the one consumer ABOVE is disclosed rather than implied: the docs-only
# enumeration still runs against the snapshot `$base_ref` and can `decide allow` before reaching
# this point. That is bounded and deliberate — a docs-only match is a PASSTHROUGH to the ordinary
# human prompt, never an auto-grant, so a stale base there costs a prompt someone was going to see
# anyway. Every path that can GRANT passes through the check below.
#
# `$base_ref` above comes from the PR snapshot taken at the top of this hook, and the docs-only
# enumeration between there and here is up to forty round trips. A PERSISTENT retarget in that gap
# needs no ABA and no force-push: every base-dependent decision below would be formed against a
# branch the PR no longer targets. Checking a stale identifier is not checking — which is the whole
# of `process.check-and-use-pins-a-version`, so the guard enforcing that rule must not break it.
#
# This re-read first landed inside the scheduled-auto-merge branch only, which fixed the branch-
# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot. Cold
# review demonstrated the consequence with this repo's own fixture: scheduled+retarget denied, while
# immediate+retarget auto-GRANTED. That is the twin-missed shape — a fix applied to the path where it
# was noticed — so the re-read is hoisted above every consumer rather than duplicated into each.
prjson_now=$(gq "repos/$owner/$repo/pulls/$pr")
if [ -z "${prjson_now//[[:space:]]/}" ] || ! printf '%s' "$prjson_now" | jq -e 'type == "object"' >/dev/null 2>&1; then
decide ask "H10 merge gate: could not re-read PR #$pr to confirm it still targets '$base_ref' before checking the verdict against it. Confirm the target branch, then merge."
fi
base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""' 2>/dev/null || true)
if [ -z "$base_now" ]; then
live_base=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
if [ -z "$live_base" ]; then
decide ask "H10 merge gate: PR #$pr reports no base branch (.base.ref), so the verdict cannot be checked against the branch it was formed for (ersatztv#632). Confirm the PR still targets the branch it was reviewed against before merging."
fi
if [ -n "$base_ref" ] && [ "$base_now" != "$base_ref" ]; then
decide deny "H6/H10 merge gate: BLOCKED — PR #$pr was retargeted from '$base_ref' to '$base_now' while this gate was evaluating. Every check formed against '$base_ref', including the changed-file enumeration and the review verdict, describes a merge that is no longer the one being requested (ersatztv#632). Re-review against '$base_now' and run: scripts/post-review-verdict.sh $pr MERGEABLE"
fi
# From here on both names are the freshly-confirmed base; they are equal by the check above.
base_ref=$base_now
live_base=$base_now
if [ -n "$sha" ]; then
# This is the THIRD read of this endpoint in a worst-case hook run (the ordinary-CI branch and the
# scheduled-auto-merge branch each do their own). Sharing one snapshot would close a narrow
@@ -341,19 +308,8 @@ else
# status decide this. Here the fallthrough happens to land on `vstate=""` -> deny (fail-CLOSED,
# so this was never a hole), but it would have surfaced the wrong message — a "BLOCKED, no
# verdict" deny instead of the "could not read the status" ask this branch exists to give.
# Validate the MEMBERS, not just the array. `.statuses | type == "array"` passes for
# `{"statuses":[1]}`, and the extraction below then errors with "Cannot index number with string"
# and exits 5 — which, under `set -e`, aborts this hook with NO JSON on stdout at all. A consent
# hook that emits nothing has violated its own contract: it neither grants, denies nor asks. Same
# one-level-down swallow as the #632 base-change guard and the branch-protection shape check
# below; the validation domain must match the CONSUMPTION domain (ersatztv#778).
if [ -z "${vjson//[[:space:]]/}" ] \
|| ! printf '%s' "$vjson" \
| jq -e '(.statuses | type == "array")
and all(.statuses[]; type == "object"
and ((.context | type) == "string")
and ((.status | type) == "string"))' >/dev/null 2>&1; then
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable, or a response whose status rows are not the expected shape). Confirm the current head is reviewed before scheduling an auto-merge."
if [ -z "${vjson//[[:space:]]/}" ] || ! printf '%s' "$vjson" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable or an unexpected response). Confirm the current head is reviewed before scheduling an auto-merge."
fi
vstate=$(printf '%s' "$vjson" | jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .status // ""')
case "$vstate" in
@@ -362,210 +318,6 @@ else
pending) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is still pending on PR #$pr head ${sha:0:7} (no verdict posted for this commit yet). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
*) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is '$vstate' on PR #$pr head ${sha:0:7}. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
esac
# --- The mitigation this path RESTS on, verified instead of asserted (ersatztv#778). -----------
# Everything above proves a property of the head that exists NOW. What makes that safe under
# merge_when_checks_succeed is stated in the paragraph opening this branch: `review-verdict/h10`
# is a REQUIRED status check on the base, a commit status belongs to exactly ONE sha, so a commit
# pushed after scheduling cannot inherit it and Gitea's own gate refuses the merge.
#
# That guarantee is branch-protection CONFIG. It lives outside this repo, no code here owned it,
# and until #778 nothing compared the two — so the grant reason handed to a human cited a
# protection that could have been switched off with no signal anywhere. The comment above and the
# grant string below are claims about the past; a dated claim is not a check.
#
# This is the hook's OWN defect class (#778 / `process.check-and-use-pins-a-version`): a check
# ("a later push clears the status") authorizes an action ("arm an auto-merge that Gitea completes
# later") over state that can change in between, with nothing pinning it. The read here does not
# pin anything either — branch protection can still be edited after this call — but it converts an
# ASSUMPTION that was never observed into a precondition that is, which is the honest ceiling for
# a config whose API offers no version, ETag or conditional read.
#
# Tri-state, matching this file's idiom throughout: unreadable -> ask (a human adjudicates),
# present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened,
# and the whole point of that issue is that the failure is silent from the merge caller's side.
# Belt-and-braces: `$base_ref` was proven non-empty and re-confirmed at the hoisted check above,
# so this cannot fire today. Kept because it is the precondition this block's URL depends on, and
# a future edit that moves either piece should fail loudly here rather than request a URL with an
# empty path segment.
[ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling."
# The base was re-read and confirmed unchanged above, for every path — see the hoist comment
# there. It is deliberately NOT re-read a second time here: two reads would create a window
# between them for no gain, and the hoisted check already covers the enumeration gap that made
# this necessary.
# A read failure here is NOT evidence about the branch. The deleted by-name endpoint answered 404
# for "no rule with this name", which was a finding; the LIST endpoint's 404 means the repo was not
# found or is invisible to this credential, which is a read failure. Absence is now established by
# the classifier returning `nomatch` over a list that WAS read, never by an HTTP status.
# ALWAYS enumerate the rule LIST; never look a rule up by name. The by-name endpoint
# (`branch_protections/{name}`) is an exact DB lookup — `GetProtectedBranchRuleByName` — which
# performs no matching and knows nothing about precedence, so a 200 from it means only "a rule
# with this NAME exists and lists this context", never "this context is required on this branch".
#
# It was used first, with the list consulted only on a 404, and cold review found what that left
# behind: the precedence argument below guarded the 404 path while the 200 path — the one this
# repo actually takes — granted without it. Given a rule `main` requiring `review-verdict/h10` and
# a rule `m*` with better Priority that does not, Gitea applies `m*`, and the by-name hit on
# `main` granted anyway. The hardened path was dead code and the unhardened one was live. Deleting
# the twin rather than documenting it is the point: one fetch, one classifier, one argument, and
# no second path to keep in step. The ref no longer reaches a URL segment, so it needs no
# encoding either.
bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
else
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
fi
bp_list=$(cat "$bp_file" 2>/dev/null || true)
bp=""
if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then
# DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would
# be the exact defect this PR records (a mitigation outside the code, asserted rather than
# verified). Gitea compiles a rule name with gobwas/glob and a `/` separator, so its `*` does
# NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards, and a plain name is folded case-
# insensitively. Reimplementing that here would be a second copy of somebody else's parser.
#
# So the classification is deliberately THREE-way, and each arm is safe without knowing the
# dialect:
# exact — no glob rule could apply, AND some rule name has no glob metacharacter and
# equals the base case-insensitively. Only then is a single rule decidable.
#
# UNDECIDABLE IS EVALUATED FIRST, and the order is the point. Gitea picks the
# governing rule with `GetFirstMatched` over a list sorted by Priority, THEN
# by plain-name-ness — so a glob rule with a better Priority outranks an
# exactly-named one. Preferring `exact` would therefore inspect a rule Gitea
# might not be applying: if the exact rule requires `review-verdict/h10` and a
# higher-priority glob rule does not, the gate auto-grants on a base where the
# check is not enforced. Asking whenever ANY glob rule could apply is sound
# without knowing the precedence rules at all, which is the only claim this
# code is entitled to make about somebody else's resolver.
#
# Case folding is ASCII-only here, while Gitea's `EqualFold` is
# Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there
# and not here. ASCII-fold equality implies EqualFold equality, so the gap can
# only MISS a match, never invent one; but a miss lands on `none`, which
# DENIES with the stated cause that no rule can govern the base. The backslash
# paragraph below rejects "nearly unreachable" as a standard for that arm, and
# the same standard has to apply here, so a rule name carrying any non-ASCII
# byte is `undecidable` rather than fold-compared. Two fold-equal plain names
# are undecidable too: this code picks by list order while Gitea picks by
# Priority, and guessing which one is enforced is the defect the arm order
# above exists to avoid.
# undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any
# glob dialect: literal prefix before the first metacharacter, `.*`, literal
# suffix after the last. If even that does not match, no dialect can, because
# every dialect requires the literal head and tail to match literally.
#
# BACKSLASH counts as a metacharacter for that purpose, and it is the one case that breaks the
# superset proof if it does not. gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b`
# governs the base `a{b` — while a superset that treated `\` as literal would build `a\.*b`,
# fail to match, and answer `none`, i.e. deny a base that IS protected. Git ref rules make this
# nearly unreachable (a branch name may not contain `*`, `?`, `[` or `\`, though it MAY contain
# `{`), but `none` is the arm that authorises a DENY on the stated grounds "nothing can govern
# this base", so its premise has to hold unconditionally rather than usually.
# none — nothing can possibly govern the base, so it is genuinely unprotected.
#
# `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base
# whose protection we never established (#622's hole, reached through the block written to
# close it); under-matching would deny with a stated cause that is false, which this block's
# own comment calls the worse outcome. Asking is the only answer that is honest in both
# directions, and it is rare in practice: as of 2026-08-19 this repo's only rule is the plain
# name `main`, which the classifier resolves to `exact` on every run. That is a dated
# observation about mutable remote config, not a property to rely on.
bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c '
def esc: gsub("(?<c>[.+?^${}()|\\[\\]\\\\])"; "\\" + .c);
def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset];
def superset: . as $n | (offs) as $o
| ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc);
def nonascii: explode | any(. > 127);
. as $rules | $b as $base |
($rules | map(select((.branch_name // .rule_name // "") as $n
| (($n|offs|length) == 0)
and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts |
(($base|nonascii) or ($rules | any((.branch_name // .rule_name // "") as $n
| ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable |
if ($rules | any((.branch_name // .rule_name // "") as $n
| (($n|offs|length) > 0)
and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"}
elif $unfoldable then {verdict:"undecidable"}
elif ($exacts | length) > 1 then {verdict:"undecidable"}
elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)}
else {verdict:"none"} end' 2>/dev/null || true)
case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;;
undecidable) rm -f "$bp_file"
decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
none) bp_code=nomatch; bp="" ;;
*) bp_code=unreadable-rules; bp="" ;;
esac
else
# A 200 whose body is NOT an array never reaches the classifier — it is diverted by the array
# gate above — so it needs the same sentinel, or the generic ask below reports
# "HTTP '200' — Gitea unreachable" about a read that plainly succeeded. Same defect as the
# throw-inside-the-classifier arm, one branch earlier; fixing only the arm where it was noticed
# is the twin-missed shape this PR is largely about.
if [ "$bp_code" = "200" ]; then
bp_code=unreadable-rules
else
bp_code=${bp_code:-000} # a real transport/HTTP failure -> the ask arm below
fi
bp=""
fi
rm -f "$bp_file"
# `nomatch` is the CLASSIFIER's verdict, deliberately not an HTTP code. Reusing 404 for it made
# this deny reachable from an HTTP 404 on the list read too — repo not found, or invisible to the
# credential, which Gitea also answers 404 — and then the reason claimed "the full rule list was
# read and none matches" about a read that never happened. A transport failure must reach the ask
# below, not a deny stating a finding.
if [ "$bp_code" = "nomatch" ]; then
decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green."
fi
# `unreadable-rules` is the CLASSIFIER failing on a 200 it could not parse — a numeric
# `branch_name` makes jq throw, and `//` does not catch it because it fires only on null/false.
# It gets its own sentinel for the same reason `nomatch` does: reporting "HTTP '000' — Gitea
# unreachable" about a successful 200 read states a cause that did not happen, which is the defect
# fixed one arm over for the deny.
if [ "$bp_code" = "unreadable-rules" ]; then
decide ask "H6/H10 merge gate: this repo's branch-protection rules came back in a shape this hook could not parse, so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Check the rules manually, or merge immediately instead of scheduling."
fi
if [ "$bp_code" != "200" ] || [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then
decide ask "H6/H10 merge gate: could not read this repo's branch-protection rules (HTTP '${bp_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling."
fi
# The membership test is `any(.[]; . == …)` over a value FIRST PROVEN to be an array of strings —
# never `index()`. `index` on a STRING is substring search, so a `status_check_contexts` that
# arrived as the string "prefix-review-verdict/h10-suffix" would answer "yes" and auto-grant a
# merge on a base where no such context is required. That is a FALSE-OPEN in the gate, reachable
# from any payload shape drift, and it is the direction that matters: a false-closed costs a
# prompt, a false-open costs an unreviewed merge.
#
# Validating `$bp` as an object does not make its MEMBERS well-formed, which is the same
# one-level-down swallow that survived the first fix in the #632 base-change guard — the
# validation domain has to match the CONSUMPTION domain, not stop at the top-level type. So the
# shape is checked explicitly and anything else becomes "unknown" rather than a decision.
#
# `null` and `[]` are legitimate (an unprotected-in-practice branch) and answer "no", not
# "unknown": absent IS the finding here, not a read failure. The word is then matched
# exhaustively, because "" is not a third synonym for "no".
# `// []` defaults on FALSE as well as on null, because jq's alternative operator fires for both.
# So `"status_check_contexts": false` — a malformed shape — became `[]` and answered "no", i.e. a
# confident DENY derived from a payload that was never understood. Absent and null are defaulted
# explicitly; every other non-array is "unknown".
# `enable_status_check` is validated as a BOOLEAN before it is trusted, for the same reason the
# contexts list is: `"true"` (the string) is not `true`, and comparing it to `true` yields a
# confident "no" -> deny derived from a payload never understood. Every malformed shape on this
# endpoint has to reach the same "unknown" -> ask arm, or the tri-state is only two states.
guarded=$(printf '%s' "$bp" \
| jq -r 'def ctxs: if (has("status_check_contexts") | not) or .status_check_contexts == null
then [] else .status_check_contexts end;
if (.enable_status_check | type) != "boolean" then "unknown"
elif (ctxs | type) != "array" or any(ctxs[]; type != "string") then "unknown"
elif (.enable_status_check == true) and any(ctxs[]; . == "review-verdict/h10") then "yes"
else "no" end' 2>/dev/null || true)
case "$guarded" in
yes) : ;;
no) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is NOT a required status check on '$base_ref' (branch protection reports enable_status_check/status_check_contexts without it). A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622); without it, arming merge_when_checks_succeed freezes consent at a head Gitea may not be the one to merge. Restore it in branch protection, or merge immediately (without merge_when_checks_succeed) once CI is green." ;;
*) decide ask "H6/H10 merge gate: branch protection for '$base_ref' came back in an unexpected shape, so the 'review-verdict/h10' required check that makes a scheduled auto-merge safe (ersatztv#622) could not be confirmed either way. Check it manually, or merge immediately instead of scheduling." ;;
esac
fi
# --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the
@@ -620,7 +372,7 @@ if [ "$class" = "positive" ]; then
# path this hook never read the CI status at all (it is delegated to Gitea), so saying "CI green"
# there was a plain falsehood in the one message a human reads to decide whether to trust the gate.
if [ "$mwcs" = "true" ]; then
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed). A commit pushed before Gitea merges clears the sha-bound verdict status and is blocked by the 'review-verdict/h10' required check (ersatztv#622) — which this hook has just CONFIRMED is still required on '$base_ref' — read from the repo's full rule list and matched with Gitea's own plain-vs-glob split, refusing rather than guessing wherever precedence or folding is not derivable. That guarantee holds while that branch protection stands; if it is weakened after this check, nothing here would see it (ersatztv#778). Auto-granted."
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed), and because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it and block the merge (ersatztv#622). Auto-granted."
fi
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
fi
-21
View File
@@ -114,27 +114,6 @@ env:
MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering
jobs:
# Answers "is the toolchain image still there?" in ONE place, so a deleted pin does not read as
# five broken jobs and a broken diff (ersatztv#772). Deliberately container-free and deliberately
# NOT a `needs:` of the jobs it diagnoses — see scripts/ci-toolchain-image-resolves.sh for both
# decisions and for the cleanup-rule root cause it cannot fix from this repo.
toolchain-preflight:
name: CI toolchain image resolves
runs-on: small
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve the pinned toolchain tag in the registry
env:
ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark resolve
scripts/ci-toolchain-image-resolves.sh
- name: Assert every expected step executed (ersatztv#756)
run: >-
scripts/ci-step-ran.sh assert
--always resolve
test:
name: Build & test (.NET)
runs-on: ubuntu-latest
+15 -115
View File
@@ -159,36 +159,6 @@ jobs:
echo "Parity-doc reminder: nothing to flag."
fi
# ersatztv#784 — ADVISORY nudge for `docs.no-session-narrative`. Deliberately NON-BLOCKING and
# deliberately in this job rather than a gate of its own: it is a string predicate over prose,
# and `docs/defect-shapes-773.md` §4 argues that class must not be load-bearing. The script
# exits 0 on every path (asserted per argument shape in scripts/tests/test_check_doc_narrative.py,
# not only in prose), so this step cannot redden the run even on a hit; if you find yourself
# wanting it to fail, read the decision record first — it says no in as many words.
# `python3` is not guaranteed on the bare `small` lane (docs/ci-cd.md), and every other
# python-using job on it declares this. Without it a missing interpreter is exit 127 — a RED
# advisory job joining the combined status, which is the one thing this step must never be.
#
# Both steps carry `continue-on-error` because the SCRIPT exiting 0 is not the whole invariant:
# a setup-python download failure reddens the job just as effectively as a hit would, and an
# advisory red still joins the combined status the merge gate reads (ersatztv#598). Scope,
# stated rather than implied: this covers the two steps that exist to run the check. A failed
# `Checkout` is NOT covered and deliberately so — with no tree there is nothing to check, and
# a job that cannot run is a different failure from an advisory one that ran and disagreed.
# Measured on this runner (PR#811, run 2179): the job reports `success` and the commit status
# context is `success` with both steps green under `continue-on-error`.
- name: Set up Python
uses: actions/setup-python@v5
continue-on-error: true
with:
python-version: '3.x'
- name: Warn when a doc narrates its own revision history
continue-on-error: true
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
python3 scripts/check-doc-narrative.py --diff "origin/${base_ref}"
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
# supersedes/superseded-by links, no rationale-prose rewrite without a Decisions-Edit: yes git
@@ -359,7 +329,7 @@ jobs:
[ "$failed" -eq 0 ]
script-tests:
name: Script lint and tests (ruff + pytest)
name: Script tests (pytest)
runs-on: small
if: github.event_name == 'pull_request'
steps:
@@ -369,90 +339,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: '3.x'
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
# Two consumers need `git`: the lint steps below derive their population from `git ls-files`,
# and test_post_review_verdict.py / test_merge_consent_exemption.py exec the REAL
# post-review-verdict.sh / pretooluse-merge-consent.sh. `curl` those tests shim on PATH; `jq`
# and `git` they do NOT. It stays AHEAD of the lint steps, not merely ahead of pytest: without
# it, a missing git reaches the lint steps as an empty population, which they report as a
# population problem. One actionable line beats a misdirected one, and beats the wall of
# unattributable assertion failures the suite produces without git.
- name: Preflight external tools
run: |
if ! command -v git >/dev/null 2>&1; then
echo "::error::script-tests needs git on PATH but it is absent. The lint steps derive" \
"their population from it and the suite execs real shell scripts that use it." \
"Bake it into the runner image rather than apt-get installing here (ersatztv#390)."
exit 1
fi
echo "Preflight OK: $(git --version)"
# ersatztv#780. Lint runs EARLY — after the git preflight it depends on, but before the test
# dependencies, the jq preflight and the ~4-minute pytest run. A style red therefore arrives in
# seconds, and, more importantly, the lint does not sit behind `Preflight jq version`: that is
# an `--expect` tripwire, so a runner jq bump would take the lint dark for as long as the jq
# contract is broken, under a red that says "jq".
#
# The version is PINNED: an unpinned ruff makes the verdict a function of whenever the job ran
# — the same environment-divergence the committed ruff.toml exists to close. Bumping it is a
# deliberate PR (new rules may fire), exactly like the jq pin below. `pytest`/`pyyaml` are
# deliberately NOT pinned: a pytest release does not add assertions to your suite, a ruff
# release adds rules to your lint.
- name: Install ruff
run: python3 -m pip install --disable-pip-version-check --quiet 'ruff==0.12.11'
# POPULATION. Both steps lint an EXPLICIT list from `git ls-files`, never `ruff check .`, and
# pass `--no-force-exclude`. Measured with ruff 0.12.11 and `exclude = ["scripts/**"]` — a
# per-FILE pattern, because `exclude` matches per file: a bare `["scripts"]` still works at the
# top level but matches nothing under `[lint]`/`[format]`. The subject is a planted tracked file
# holding an unused import, a hardcoded credential and a formatting error. GREEN means the gate
# was silently off:
#
# DISCOVERY FORM EXPLICIT FORM (what ships)
# exclude scope check . format --check . check format --check
# top-level GREEN GREEN red red
# [lint] GREEN red red red
# [format] red GREEN red red
# top + force-exclude GREEN GREEN red red <- with the flag
# GREEN GREEN <- without it
#
# Only the top-level scope empties BOTH discovery commands; `[lint]` empties `check` and
# `[format]` empties `format --check`, so in those two the job would still redden on the other
# step. `[format]` is where a line appended to ruff.toml lands, by TOML rules. `include = []`,
# `extend-exclude` and a nested `scripts/ruff.toml` behave the same way and are equally inert
# against the explicit form. The last row is the whole reason for `--no-force-exclude`:
# `force-exclude = true` re-applies excludes to explicitly-passed paths, and is the one setting
# that reaches explicitly-passed paths at all.
#
# `ruff check .` over an empty tree exits **0** with only a stderr warning, so every GREEN above
# is a gate that was switched off without a red.
#
# This also derives the population from source rather than from the filesystem
# (docs/decisions/records/testing/guard-derives-population-from-source.md) and covers
# tracked-but-gitignored files, which `ruff check .` skips. The empty-population arm is the
# anti-vacuity check: a completeness check whose population is empty reports that it proved
# everything. What it does NOT cover: an emptied RULE set. `select = []` silences every selected
# rule, so the `ruff check` step goes green over any lint violation (a syntax error still reds)
# while printing a reassuring file count.
# `ruff format --check` is unaffected, because formatting is not rule-selected. So half the
# gate is killable by a config edit, and only a human reading that edit catches it.
- name: Lint scripts (ruff check)
run: |
mapfile -d '' -t PYFILES < <(git ls-files -z '*.py' '*.pyi' '*.ipynb')
if [ "${#PYFILES[@]}" -eq 0 ]; then
echo "::error::the lint population is EMPTY — git tracks no Python files. Either the" \
"checkout is wrong or the glob is. A lint over nothing passes; see ersatztv#780."
exit 1
fi
echo "Linting ${#PYFILES[@]} tracked Python files"
python3 -m ruff check --no-force-exclude -- "${PYFILES[@]}"
- name: Lint scripts (ruff format --check)
run: |
mapfile -d '' -t PYFILES < <(git ls-files -z '*.py' '*.pyi' '*.ipynb')
if [ "${#PYFILES[@]}" -eq 0 ]; then
echo "::error::the format population is EMPTY — git tracks no Python files. See ersatztv#780."
exit 1
fi
echo "Format-checking ${#PYFILES[@]} tracked Python files"
python3 -m ruff format --check --no-force-exclude -- "${PYFILES[@]}"
# pytest + PyYAML. PyYAML is NOT a contradiction of the dependency-free decisions READ path:
# `decisions_lib._read_frontmatter` is hand-written precisely so validation runs where nothing
# is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by
@@ -463,6 +349,20 @@ jobs:
# went red in CI on a collection error.
- name: Install test dependencies
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
# test_post_review_verdict.py and test_merge_consent_exemption.py exec the REAL
# post-review-verdict.sh / pretooluse-merge-consent.sh, which shell out to `jq` ~26 times.
# `curl` those tests shim on PATH; `jq` they do NOT. If it were missing, the suite would fail
# as ~20 opaque assertion errors — this turns that into one actionable line.
- name: Preflight external tools
run: |
if ! command -v git >/dev/null 2>&1; then
echo "::error::script-tests needs git on PATH but it is absent. The suite execs real" \
"shell scripts that use it. Bake it into the runner image rather than apt-get" \
"installing here (see ersatztv#390)."
exit 1
fi
echo "Preflight OK: $(git --version)"
# jq gets its OWN step because its VERSION, not merely its presence, is load-bearing
# (ersatztv#648). `--expect` makes this a TRIPWIRE: scripts/tests exercises the jq 1.6 code path
# only because this runner ships 1.6, so an upgrade would silently delete that coverage — and
+2 -2
View File
@@ -6,7 +6,7 @@
<ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="CliWrap" Version="3.10.5" />
<PackageVersion Include="CliWrap" Version="3.10.4" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.79" />
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
@@ -95,7 +95,7 @@
bundled SQLite, GHSA-2m69-gcr7-jv3q). The 3.x line ships the patched native
(lib.e_sqlite3 3.50.3); core 3.0.4 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
<PackageVersion Include="System.CommandLine" Version="2.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
<PackageVersion Include="Testably.Abstractions.Testing" Version="5.1.0" />
@@ -595,13 +595,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
metadata.Genres ??= [];
metadata.Studios ??= [];
// Artists/AlbumArtists are NULLABLE primitive collections, so they are guarded at the read site
// rather than assigned back onto `metadata` like the navigations above (ersatztv#701/#691): they
// are scalar JSON-array columns, so `??= []` on a tracked entity would persist `[]` over NULL.
// The shipped `_song.sbntxt` only does `array.join`, but a user template is free to do anything.
List<string> songArtists = Optional(metadata.Artists).Flatten().ToList();
List<string> songAlbumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
@@ -614,8 +607,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
SongTitle = subtitle,
SongArtists = songArtists,
SongAlbumArtists = songAlbumArtists,
SongArtists = metadata.Artists,
SongAlbumArtists = metadata.AlbumArtists,
SongHasYear = metadata.Year.HasValue,
SongYear = metadata.Year,
SongGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
@@ -440,64 +440,64 @@ public class ElasticSearchIndex : ISearchIndex
Season season)
{
foreach (SeasonMetadata metadata in season.SeasonMetadata.HeadOrNone())
foreach (ShowMetadata showMetadata in season.Show.ShowMetadata.HeadOrNone())
foreach (ShowMetadata showMetadata in season.Show.ShowMetadata.HeadOrNone())
{
try
{
try
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
.ToLowerInvariant();
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
.ToLowerInvariant();
var doc = new ElasticSearchItem
{
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
.ToLowerInvariant();
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
.ToLowerInvariant();
Id = season.Id,
Type = LuceneSearchIndex.SeasonType,
Title = seasonTitle,
SortTitle = sortTitle,
LibraryName = season.LibraryPath.Library.Name,
LibraryId = season.LibraryPath.Library.Id,
TitleAndYear = titleAndYear,
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata),
State = season.State.ToString(),
SeasonNumber = season.SeasonNumber,
ShowTitle = showMetadata.Title,
ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList(),
ShowTag = showMetadata.Tags.Map(t => t.Name).ToList(),
ShowStudio = showMetadata.Studios.Map(s => s.Name).ToList(),
ShowContentRating = GetContentRatings(showMetadata.ContentRating),
Language = GetLanguages(
languageCodeService,
await searchRepository.GetLanguagesForSeason(season)),
LanguageTag = await searchRepository.GetLanguagesForSeason(season),
SubLanguage = GetLanguages(
languageCodeService,
await searchRepository.GetSubLanguagesForSeason(season)),
SubLanguageTag = await searchRepository.GetSubLanguagesForSeason(season),
ContentRating = GetContentRatings(showMetadata.ContentRating),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
TraktList = season.TraktListItems
.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture)).ToList(),
Tag = metadata.Tags.Map(a => a.Name).ToList(),
TagFull = metadata.Tags.Map(t => t.Name).ToList()
};
var doc = new ElasticSearchItem
{
Id = season.Id,
Type = LuceneSearchIndex.SeasonType,
Title = seasonTitle,
SortTitle = sortTitle,
LibraryName = season.LibraryPath.Library.Name,
LibraryId = season.LibraryPath.Library.Id,
TitleAndYear = titleAndYear,
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata),
State = season.State.ToString(),
SeasonNumber = season.SeasonNumber,
ShowTitle = showMetadata.Title,
ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList(),
ShowTag = showMetadata.Tags.Map(t => t.Name).ToList(),
ShowStudio = showMetadata.Studios.Map(s => s.Name).ToList(),
ShowContentRating = GetContentRatings(showMetadata.ContentRating),
Language = GetLanguages(
languageCodeService,
await searchRepository.GetLanguagesForSeason(season)),
LanguageTag = await searchRepository.GetLanguagesForSeason(season),
SubLanguage = GetLanguages(
languageCodeService,
await searchRepository.GetSubLanguagesForSeason(season)),
SubLanguageTag = await searchRepository.GetSubLanguagesForSeason(season),
ContentRating = GetContentRatings(showMetadata.ContentRating),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
TraktList = season.TraktListItems
.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture)).ToList(),
Tag = metadata.Tags.Map(a => a.Name).ToList(),
TagFull = metadata.Tags.Map(t => t.Name).ToList()
};
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName, ES.Id.From(doc));
}
catch (Exception ex)
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
metadata.Season = null;
_logger.LogWarning(ex, "Error indexing season with metadata {@Metadata}", metadata);
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName, ES.Id.From(doc));
}
catch (Exception ex)
{
metadata.Season = null;
_logger.LogWarning(ex, "Error indexing season with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateArtist(
@@ -763,10 +763,8 @@ public class ElasticSearchIndex : ISearchIndex
{
try
{
// Guard the two NULLABLE primitive collections at the READ SITE, never by assigning back onto
// `metadata` (ersatztv#701) -- see the matching comment in LuceneSearchIndex.UpdateSong.
List<string> artists = Optional(metadata.Artists).Flatten().ToList();
List<string> albumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
metadata.AlbumArtists ??= [];
metadata.Artists ??= [];
var doc = new ElasticSearchItem
{
@@ -787,8 +785,8 @@ public class ElasticSearchIndex : ISearchIndex
SubLanguageTag = GetSubLanguageTags(song.MediaVersions),
AddedDate = GetAddedDate(metadata.DateAdded),
Album = metadata.Album ?? string.Empty,
Artist = artists,
AlbumArtist = albumArtists,
Artist = metadata.Artists.ToList(),
AlbumArtist = metadata.AlbumArtists.ToList(),
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList(),
TagFull = metadata.Tags.Map(t => t.Name).ToList()
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
@@ -145,7 +145,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
_directory = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
Analyzer analyzer = SearchQueryParser.AnalyzerWrapper();
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
{ OpenMode = OpenMode.CREATE_OR_APPEND };
{ OpenMode = OpenMode.CREATE_OR_APPEND };
_writer = new IndexWriter(_directory, indexConfig);
_initialized = true;
}
@@ -328,7 +328,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
using (Analyzer analyzer = SearchQueryParser.AnalyzerWrapper())
{
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
{ OpenMode = OpenMode.CREATE_OR_APPEND };
{ OpenMode = OpenMode.CREATE_OR_APPEND };
using (var w = new IndexWriter(d, indexConfig))
{
using (DirectoryReader _ = w.GetReader(true))
@@ -1318,13 +1318,8 @@ public sealed class LuceneSearchIndex : ISearchIndex
{
try
{
// Guard the two NULLABLE primitive collections at the READ SITE, never by assigning back onto
// `metadata` (ersatztv#701). The entity reaching here may be TRACKED, and Artists/AlbumArtists
// are scalar JSON-array columns rather than navigations -- so `??= []` flips the entity to
// Modified and the next SaveChanges writes `[]` over a NULL column. Same convention as
// SongVideoGenerator and MediaCollectionRepository (ersatztv#691).
List<string> artists = Optional(metadata.Artists).Flatten().ToList();
List<string> albumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
metadata.AlbumArtists ??= [];
metadata.Artists ??= [];
var doc = new Document
{
@@ -1360,12 +1355,12 @@ public sealed class LuceneSearchIndex : ISearchIndex
doc.Add(new TextField(AlbumField, metadata.Album, Field.Store.NO));
}
foreach (string artist in artists)
foreach (string artist in metadata.Artists)
{
doc.Add(new TextField(ArtistField, artist, Field.Store.NO));
}
foreach (string albumArtist in albumArtists)
foreach (string albumArtist in metadata.AlbumArtists)
{
doc.Add(new TextField(AlbumArtistField, albumArtist, Field.Store.NO));
}
@@ -1,5 +1,3 @@
using System.Collections;
using System.Reflection;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
@@ -67,8 +65,7 @@ public class ScheduleItemResponseRoundTripTests
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None);
replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
// GET again → envelope B; A and B must be semantically identical INCLUDING row ids
// the handler reconciles by id and updates in place, it does not regenerate rows.
// GET again → envelope B; A and B must be semantically identical (ignoring regenerated row ids).
ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId);
envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count);
@@ -343,97 +340,62 @@ public class ScheduleItemResponseRoundTripTests
r.PreferredSubtitleLanguageCode,
r.SubtitleMode);
// ersatztv#779 (detector G): the compared field list is DERIVED from the DTO by reflection,
// never hand-copied. The previous version was a hand-written run of `b.X.ShouldBe(a.X)` lines.
// It was COMPLETE on the day it was written — every property but Id — and had no way
// to report the day it stopped being: a field added to ScheduleItemResponseModel simply went
// uncompared, and this "lossless round-trip" test kept passing while the round trip silently
// dropped it. That is #754's mechanism exactly (a hand-maintained mirror drifting from a
// 28-property DTO by one field, HTTP 200, no error), one altitude up — in the very test whose
// job is to catch losses.
//
// Properties deliberately NOT compared. The set is EMPTY, and that is a finding rather than an
// oversight. The first version exempted Id on the reasoning that "the PUT replaces the item
// set, so B's rows are new rows with new ids". ReplaceProgramScheduleItemsHandler does not do
// that for this fixture's payload: it forwards every Id, takes the id-based reconcile, and
// updates rows in place. So Id compares equal, and the exemption was unnecessary.
//
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why,
// because three earlier drafts of this comment each supplied a confident mechanism for a
// correct observation and two of them were contradicted by the code:
//
// ToReplaceCommand passes `null` for EVERY id -> test stays GREEN
// ToReplaceCommand passes `null` for index 0 only -> test goes RED, "Id differs"
//
// So the Id comparison does discriminate; it is not decorative. What it is NOT is a substitute
// for ReplaceProgramScheduleItemsReconcileTests, whose
// Reorder_ById_Should_Move_State_With_The_Logical_Item_Not_The_Slot and
// Insert_ById_In_Middle_Should_Keep_Existing_Ids_And_State pass real ids and pin that state
// moves with the logical item rather than the slot. Those are the #252 tests; this is a
// round-trip check that happens to also notice a lost row.
//
// Any name added here must still exist on ScheduleItemResponseModel (asserted below), so
// renaming a field cannot leave a stale exemption silently exempting nothing.
private static readonly System.Collections.Generic.HashSet<string> RoundTripExemptProperties =
new(StringComparer.Ordinal);
private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
{
PropertyInfo[] properties = typeof(ScheduleItemResponseModel)
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// A stale exemption is a silent hole: it would exempt nothing while reading as a reviewed
// decision, and the property it once named would be compared or not by accident.
foreach (string exempt in RoundTripExemptProperties)
{
properties.Any(p => p.Name == exempt).ShouldBeTrue(
$"'{exempt}' is exempted from the round-trip comparison but is not a property of "
+ $"{nameof(ScheduleItemResponseModel)}; remove the stale exemption or fix the name.");
}
var compared = 0;
foreach (PropertyInfo property in properties)
{
if (RoundTripExemptProperties.Contains(property.Name))
{
continue;
}
object? expected = property.GetValue(a);
object? actual = property.GetValue(b);
if (expected is IEnumerable expectedSequence and not string)
{
// Collection-valued members (WatermarkIds, Watermarks, GraphicsElementIds,
// GraphicsElements). The elementwise walk still delegates to each element's Equals,
// so it is value equality only because those elements are records
// (NamedIdResponseModel) or value types (the int id lists); a future element type that
// is neither would silently be compared by REFERENCE inside this loop. It is also order-sensitive, which is
// correct for these ordered lists but would be wrong for an unordered type such as
// a dictionary-valued property.
actual.ShouldNotBeNull($"{property.Name} was null on the round-tripped item");
var actualSequence = (IEnumerable)actual;
actualSequence.Cast<object?>().ToList()
.ShouldBe(expectedSequence.Cast<object?>().ToList(), $"{property.Name} differs");
}
else
{
actual.ShouldBe(expected, $"{property.Name} differs");
}
compared++;
}
// Anti-vacuity, as a PIN rather than a floor. A `>=` floor lets properties vanish silently,
// which is the one-sided version of the both-directions rule this test is meant to embody.
// Comparing against the reflected count minus exemptions would be tautological — both sides
// come from the same reflection — so the expected number is written down and must be
// bumped deliberately in the same change that adds or removes a DTO field.
const int expectedComparedProperties = 55;
compared.ShouldBe(
expectedComparedProperties,
$"{compared} properties were compared, expected {expectedComparedProperties}; update "
+ "this pin in the same change that alters ScheduleItemResponseModel's field list");
b.Index.ShouldBe(a.Index);
b.StartType.ShouldBe(a.StartType);
b.StartTime.ShouldBe(a.StartTime);
b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior);
b.PlayoutMode.ShouldBe(a.PlayoutMode);
b.CollectionType.ShouldBe(a.CollectionType);
b.CollectionId.ShouldBe(a.CollectionId);
b.MultiCollectionId.ShouldBe(a.MultiCollectionId);
b.SmartCollectionId.ShouldBe(a.SmartCollectionId);
b.RerunCollectionId.ShouldBe(a.RerunCollectionId);
b.MediaItemId.ShouldBe(a.MediaItemId);
b.PlaylistId.ShouldBe(a.PlaylistId);
b.SearchTitle.ShouldBe(a.SearchTitle);
b.SearchQuery.ShouldBe(a.SearchQuery);
b.PlaybackOrder.ShouldBe(a.PlaybackOrder);
b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy);
b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups);
b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems);
b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize);
b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode);
b.MultipleMode.ShouldBe(a.MultipleMode);
b.MultipleCount.ShouldBe(a.MultipleCount);
b.PlayoutDuration.ShouldBe(a.PlayoutDuration);
b.TailMode.ShouldBe(a.TailMode);
b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts);
b.CustomTitle.ShouldBe(a.CustomTitle);
b.GuideMode.ShouldBe(a.GuideMode);
b.PreRollFillerId.ShouldBe(a.PreRollFillerId);
b.MidRollFillerId.ShouldBe(a.MidRollFillerId);
b.PostRollFillerId.ShouldBe(a.PostRollFillerId);
b.TailFillerId.ShouldBe(a.TailFillerId);
b.FallbackFillerId.ShouldBe(a.FallbackFillerId);
b.WatermarkIds.ShouldBe(a.WatermarkIds);
b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds);
b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode);
b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle);
b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode);
b.SubtitleMode.ShouldBe(a.SubtitleMode);
b.CollectionName.ShouldBe(a.CollectionName);
b.MultiCollectionName.ShouldBe(a.MultiCollectionName);
b.SmartCollectionName.ShouldBe(a.SmartCollectionName);
b.RerunCollectionName.ShouldBe(a.RerunCollectionName);
b.PlaylistName.ShouldBe(a.PlaylistName);
b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId);
b.MediaItemName.ShouldBe(a.MediaItemName);
b.PreRollFillerName.ShouldBe(a.PreRollFillerName);
b.MidRollFillerName.ShouldBe(a.MidRollFillerName);
b.PostRollFillerName.ShouldBe(a.PostRollFillerName);
b.TailFillerName.ShouldBe(a.TailFillerName);
b.FallbackFillerName.ShouldBe(a.FallbackFillerName);
b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name)));
b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name)));
b.Name.ShouldBe(a.Name);
b.DurationEstimate.ShouldBe(a.DurationEstimate);
}
private async Task<int> SeedScheduleAndReferences(bool shuffleScheduleItems)
@@ -1,230 +0,0 @@
using System.Reflection;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Tests.Support;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Index;
using Lucene.Net.Store;
using Lucene.Net.Util;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Integration;
/// <summary>
/// ersatztv#701, EXECUTED against a real <see cref="TvContext" /> on SQLite.
/// <para>
/// <b>The defect.</b> <c>LuceneSearchIndex.UpdateSong</c> opened with
/// <c>metadata.AlbumArtists ??= []; metadata.Artists ??= [];</c>. Unlike the navigation
/// collections guarded the same way all around them, these two are SCALAR COLUMNS: the whole
/// list lives in one column, so the property IS the column value. They are two of EIGHT such
/// columns in the model — the population is derived from the MODEL CONFIGURATION (EF-native
/// primitive collections plus the six <c>HasConversion&lt;*CollectionValueConverter&gt;</c>
/// columns on <c>ProgramScheduleAlternate</c>/<c>PlayoutTemplate</c>), NOT by grepping the
/// domain classes for <c>IList&lt;string&gt;</c>, which finds only two of the eight. See
/// the decision record <c>media.nullable-primitive-collection-mutation</c>.
/// Assigning one on a TRACKED entity flips it to <see cref="EntityState.Modified" />, and the next
/// <c>SaveChanges</c> writes <c>[]</c> over what the database held as <c>NULL</c> — the exact
/// mechanism an adversarial review demonstrated in ersatztv#691, which is why that issue's
/// entity-level guard was reverted in favour of guarding at the READ SITE.
/// </para>
/// <para>
/// <b>Why the fixture loads the song TRACKED even though production does not.</b> Both feeds into
/// the indexer are <c>AsNoTracking()</c> today — <c>SearchRepository.GetItemToIndex</c> and
/// <c>SearchRepository.GetAllSongs</c> — so no shipped caller loses data. That is a property of
/// today's two callers, not of the indexer, and it is exactly what ersatztv#691 recorded as "a
/// loaded gun". This fixture therefore pins the INDEXER's own contract: handed a tracked entity it
/// must not mutate it. Run against the real pre-fix file, the FIRST of the numbered assertions
/// below fails (<c>metadata.Artists should be null but was []</c>) and the run stops there;
/// reaching the persistence half needs a probe variant with assertions 1 and 2 replaced by
/// prints, which reports <c>Modified</c> and the column moving from <c>NULL</c> to <c>[]</c>.
/// Each was separately shown discriminating. A future caller that drops <c>AsNoTracking</c>
/// therefore cannot reintroduce the data loss silently.
/// </para>
/// <para>
/// The Lucene <see cref="IndexWriter" /> is injected into the private field rather than obtained via
/// <c>Initialize</c>, because <c>Initialize</c> writes to <c>FileSystemLayout.SearchIndexFolder</c> —
/// a process-wide static resolved once from <c>ETV_CONFIG_FOLDER</c>, i.e. the developer's real
/// application data folder. Letting the writer throw instead is NOT an option here: the
/// <c>catch</c> in <c>UpdateSong</c> assigns <c>metadata.Song = null</c>, which would itself dirty
/// the entity under test and make the probe report the wrong cause.
/// </para>
/// </summary>
[TestFixture]
public class SongIndexerMetadataMutationTests
{
[Test]
public async Task UpdateSong_Must_Not_Mutate_Nullable_Artists_On_A_Tracked_Entity()
{
await using var harness = await InMemoryTvContext.CreateAsync();
int metadataId;
await using (TvContext context = harness.CreateContext())
{
var library = new LocalLibrary { Name = "Music", MediaKind = LibraryMediaKind.Songs };
context.Add(library);
await context.SaveChangesAsync();
var libraryPath = new LibraryPath { Path = "/music", LibraryId = library.Id };
context.Add(libraryPath);
await context.SaveChangesAsync();
var song = new Song
{
LibraryPathId = libraryPath.Id,
MediaVersions = [],
SongMetadata =
[
new SongMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = "Untagged Track",
SortTitle = "untagged track",
DateAdded = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
// The shape FallbackMetadataProvider.GetSongMetadata leaves behind: it never
// assigns either primitive collection, so both columns persist as NULL.
Artists = null!,
AlbumArtists = null!,
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Artwork = [],
Guids = []
}
]
};
context.Add(song);
await context.SaveChangesAsync();
metadataId = song.SongMetadata[0].Id;
}
// The seed must actually have produced NULL columns, or every assertion below is vacuous.
(await ReadRawArtists(harness, metadataId)).ShouldBeNull();
await using (TvContext context = harness.CreateContext())
{
// Deliberately TRACKED -- see the fixture docstring.
Song tracked = await context.Songs
.IncludeForSearch()
.AsSplitQuery()
.SingleAsync();
SongMetadata metadata = tracked.SongMetadata[0];
metadata.Artists.ShouldBeNull("EF must materialize the NULL column as null, not as an empty list");
// UpdateSong wraps its whole body in a catch that logs a warning and assigns
// `metadata.Song = null` -- which severs a required relationship and cascades the metadata to
// Deleted. A silently-exercised catch would therefore make every assertion below report the
// wrong cause, so the logger fails the test instead of swallowing.
var logger = new ThrowOnWarningLogger<LuceneSearchIndex>();
var index = new LuceneSearchIndex(
new SearchQueryParser(
Substitute.For<ISmartCollectionCache>(),
Substitute.For<ILogger<SearchQueryParser>>()),
logger);
using var directory = new RAMDirectory();
using var writer = new IndexWriter(
directory,
new IndexWriterConfig(LuceneVersion.LUCENE_48, new StandardAnalyzer(LuceneVersion.LUCENE_48)));
typeof(LuceneSearchIndex)
.GetField("_writer", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(index, writer);
// A bare substitute returns null from GetAllLanguageCodes, which NPEs inside AddLanguages and
// would divert the run into the catch above.
var languageCodeService = Substitute.For<ILanguageCodeService>();
languageCodeService.GetAllLanguageCodes(Arg.Any<List<string>>()).Returns([]);
languageCodeService.GetAllLanguageCodes(Arg.Any<string>()).Returns([]);
await index.UpdateItems(
Substitute.For<ISearchRepository>(),
Substitute.For<IFallbackMetadataProvider>(),
languageCodeService,
[tracked]);
logger.Failure.ShouldBeNull("UpdateSong threw and its catch ran, so this probe measured the "
+ "error path rather than the indexing path");
// POSITIVE CONTROL. Every assertion below asserts that something did NOT happen, so all of
// them hold vacuously if UpdateSong never ran at all -- and it silently stops running if a
// future refactor gates UpdateItems on `_initialized`, which this fixture deliberately
// bypasses by injecting the writer. Verified BOTH ways by adding
// `if (!_initialized) { return Unit.Default; }` to UpdateItems (a bare `return;` does not
// compile there -- CS0126): with this line present it is the only failure, and with it
// removed the whole test PASSES while the code under test is unreachable.
// NumDocs == 1 proves the song-indexing path ran; it does NOT prove the artist loops
// specifically ran, which would need a second seeded song asserting ArtistField.
writer.NumDocs.ShouldBe(1, "UpdateSong did not index the song, so the assertions below "
+ "would pass without exercising the code under test");
// 1. The indexer left the entity alone.
metadata.Artists.ShouldBeNull();
metadata.AlbumArtists.ShouldBeNull();
// 2. ...so EF has nothing to persist. This is the assertion that fails loudly the day the
// mutation returns, even if a later refactor stopped the value from being observable above.
context.Entry(metadata).State.ShouldBe(EntityState.Unchanged);
// 3. And the save that a real caller would go on to make does not rewrite the column.
await context.SaveChangesAsync();
}
(await ReadRawArtists(harness, metadataId)).ShouldBeNull();
}
private sealed class ThrowOnWarningLogger<T> : ILogger<T>
{
public Exception? Failure { get; private set; }
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel >= LogLevel.Warning)
{
Failure ??= exception ?? new InvalidOperationException(formatter(state, exception));
}
}
}
private static async Task<object?> ReadRawArtists(InMemoryTvContext harness, int metadataId)
{
await using TvContext context = harness.CreateContext();
await using var command = context.Database.GetDbConnection().CreateCommand();
command.CommandText = $"SELECT Artists FROM SongMetadata WHERE Id = {metadataId}";
object? value = await command.ExecuteScalarAsync();
// ExecuteScalar returns CLR null both for "the column is NULL" and for "there is no such row",
// and the second is reachable: UpdateSong's catch assigns metadata.Song = null, which severs a
// required relationship and cascades the row to Deleted, so a SaveChanges on the error path
// DELETES it and a plain null check would pass for the wrong reason.
if (value is null)
{
Assert.Fail($"SongMetadata row {metadataId} no longer exists, so its Artists column cannot "
+ "be read -- the probe measured a deleted row rather than a preserved NULL.");
}
return value is DBNull ? null : value;
}
}
@@ -126,70 +126,6 @@ public class ApiKeyProviderTests
key.ShouldMatch("^[0-9a-f]{64}$");
}
// ---- Api:RequireKeyForReads, read through the REAL provider (ersatztv#779, detector F) ----
//
// Every other assertion about the read-gating posture goes through a hand-written
// FakeApiKeyProvider that is HANDED the bool (ApiAuthorizationFilterTests,
// ApiKeyEndpointRequiresKeyTests). Those fakes prove the FILTER reacts to the flag; they cannot
// see the line that DERIVES it, because they never run it. Until these tests, nothing in the
// suite constructed ApiKeyProvider at all, so a mistyped configuration key or a flipped default
// would have left the whole suite green while shipping anonymous reads (#280/#282).
//
// Api:WriteKey is set in every case purely so ResolveKey returns before touching the real
// FileSystemLayout.ApiKeyPath — the constructor would otherwise generate and persist a key into
// the live config volume. It is deliberately NOT the subject of these tests.
private static ApiKeyProvider ProviderWith(params (string Key, string Value)[] settings)
{
// The WriteKey entry is appended LAST so a caller cannot override it to empty. That is not
// hypothetical tidiness: an empty Api:WriteKey sends ResolveKey down the real path, which
// reads, generates and PERSISTS a key into the live config volume (FileSystemLayout
// .ApiKeyPath) from a unit test.
var withKey = new List<(string, string)>(settings) { (ApiKeyProvider.WriteKeyConfigurationKey, "test-key") };
return new ApiKeyProvider(Config(withKey.ToArray()), NullLogger<ApiKeyProvider>.Instance);
}
[Test]
public void Read_Gating_Is_Required_When_The_Setting_Is_Absent()
{
// The shipped default, and the case a fixture that simply OMITS the field would test by
// accident. Asserted explicitly so it is a pinned decision rather than a coincidence.
ProviderWith().RequireKeyForReads.ShouldBeTrue();
}
[TestCase("true")]
[TestCase("True")]
[TestCase("TRUE")]
public void Read_Gating_Is_Required_At_The_Explicit_Production_Value(string configured)
{
// The DENY path at the production value, which is the half #756 showed can stay invisible:
// the absent case behaving correctly says nothing about the configured one.
ProviderWith((ApiKeyProvider.RequireKeyForReadsConfigurationKey, configured))
.RequireKeyForReads.ShouldBeTrue();
}
[TestCase("")]
[TestCase("1")]
[TestCase("yes")]
public void A_Non_Boolean_Read_Gating_Value_Fails_Startup_Rather_Than_Reads(string configured)
{
// The fourth cell of the matrix, and the one an operator actually hits: `Api__RequireKeyForReads=`
// with nothing after it in a compose file, or a habitual `1`/`yes`. ConfigurationBinder returns
// the default ONLY for a null section value, so any present-but-unparseable string goes through
// BooleanConverter and throws. That is fail-CLOSED — the app refuses to start rather than
// quietly choosing a posture — and it is pinned here so a future switch to a lenient parse
// (TryParse with a fallback) cannot silently turn a typo into anonymous reads.
Should.Throw<InvalidOperationException>(() =>
ProviderWith((ApiKeyProvider.RequireKeyForReadsConfigurationKey, configured)));
}
[TestCase("false")]
[TestCase("False")]
public void Read_Gating_Is_Waived_Only_By_An_Explicit_Opt_Out(string configured)
{
ProviderWith((ApiKeyProvider.RequireKeyForReadsConfigurationKey, configured))
.RequireKeyForReads.ShouldBeFalse();
}
[Test]
public void Returns_A_Usable_Key_Even_When_Persist_Fails()
{
+2 -15
View File
@@ -24,12 +24,8 @@ doc below, or that changes which sections a task signal points to.**
| Concurrency / optimistic-locking work | `docs/api-conventions.md` §7a/b/c + `docs/decisions/optimistic-concurrency.md` |
| Auth / security-surface work | `docs/decisions/api-auth-security.md` |
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the three rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite) |
| Testing a surface gated by config / an env var / a credential | `docs/decisions/records/testing/deny-path-at-production-config-value.md` — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch |
| Touching a full-replace write path or a hand-built request object | `docs/decisions/records/testing/full-replace-asserts-field-list.md` — derive the field list from the DTO and assert set equality; reconcile by id where child state exists. In the SPA the same rule is enforced by the type system: `docs/spa-conventions.md` §4b — build the body as `Complete<T>`, annotating BOTH the wrapper parameter and every construction site |
| Writing or editing any doc, or answering a review finding in prose | `docs/decisions/records/docs/no-session-narrative.md` — the doc records the END STATE; the path to it goes in the commit message. Apply the who-benefits test, and read the carve-out before you cut (dated measurements, stated snapshot boundaries and tested-and-rejected results stay) |
| Adding / changing / deleting a guard file | `docs/guard-inventory.md` — every guard's row is machine-checked by `scripts/tests/test_guard_inventory.py`, so a new guard must acquire a row before the suite goes green, and a row graded `MUTATION` must also acquire a declared clause in `scripts/tests/mutation_manifest.py` |
| Writing code that reads live Gitea/remote state and then acts on it | `docs/decisions/records/process/check-and-use-pins-a-version.md`, then `docs/remote-state-inventory.md` — a new executable under `scripts/` (**excluding `scripts/tests/`**), `.claude/hooks/`, `.husky/` or `.gitea/workflows/` must acquire a row there before `scripts/tests/test_remote_state_inventory.py` goes green |
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the two rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md` and `…/guard-ships-with-mutation-proof.md` |
| Adding / changing / deleting a guard file | `docs/guard-inventory.md` — every guard's row is machine-checked by `scripts/tests/test_guard_inventory.py`, so a new guard must acquire a row before the suite goes green |
| Finding every site that references a symbol (multi-site fix/sweep) | `docs/local-lsp-tooling.md` — which surface answers, and why a delegated agent must be pointed at the `csharp-lsp` MCP tools rather than the `LSP` tool |
| Live local run / Playwright-MCP verification | `docs/e2e-local.md` + `scripts/e2e-local.sh` |
| Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` |
@@ -116,15 +112,6 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
and an audit of which configured hooks/MCP servers/LSPs are actually invoked. Read it before
proposing a new guard or CI check — §4 is the detector menu, and it argues against enumerating
cases one incident at a time.
- **`docs/remote-state-inventory.md`** — every executable in `scripts/` (**excluding
`scripts/tests/`**), `.claude/hooks/`, `.husky/` and `.gitea/workflows/` that reads live remote
state and acts on that read, classified `PINNED` / `CAS` / `UNSAFE-KNOWN` / `N/A` with the window
and what bounds it. Code outside those directories — C#/TypeScript guards, `web/`, and the test
suites themselves — is out of scope, and the doc states that rather than implying coverage.
The population is derived from `git ls-files` and compared for set equality by
`scripts/tests/test_remote_state_inventory.py`, so a new script that talks to a remote service
cannot ship unclassified. Read it with `process.check-and-use-pins-a-version`; it is that record's
detector, since the class has no plausible linter (`docs/defect-shapes-773.md` §4 detector D).
- **`docs/guard-inventory.md`** — every executable guard file, what it blocks, whether it is a
`GUARD` or `TOOLING`, and whether it ships a mutation proof (`MUTATION` / `BEHAVIOUR-ONLY` /
`NONE`) with a `file::function` ref. The population is derived from the filesystem and the
+10 -191
View File
@@ -206,14 +206,10 @@ so a PR claiming many proofs costs proportionally more than the rest of the lane
`script-tests`, which is a checkout plus a `pytest` run needing only
`pytest` and `pyyaml` (ersatztv#631; it is NOT stdlib-only — that assumption is what turned the
job red on its first CI run, see below) — plus **`scan`** (ersatztv#767), the same
lightweight-Python shape, and **`toolchain-preflight`** (ersatztv#772), a checkout plus one `curl`.
Those last two are the lane members that live in `docker-build.yml` rather than `pr-checks.yml`, so
they are the ones that also run on a **tag push**. `scan` is still the one to think hardest about
before changing anything here: its failure does not merely redden a status but **skips `build`** —
an OOM or a wedge there yields no release image at all. `toolchain-preflight` is a `needs:` of nothing, by
design — it does not gate the jobs it diagnoses. It is not consequence-free either: like any red
job it lands in the PR's combined status, which the merge gate reads (see "When the pinned tag
disappears").
lightweight-Python shape. `scan` is the lane member to think hardest about before changing anything
here: it is the only one that lives in `docker-build.yml` rather than `pr-checks.yml`, so the only
one that runs on a **tag push**, and the only one whose failure does not merely redden a status but
**skips `build`** — an OOM or a wedge there yields no release image at all.
Nothing there runs a compiler or a `docker build`, which is why the lane
can be capped at 1 GiB per job. The lightweight-Python jobs are the deliberate edge of the
"git-only" rule, not an exception to it: `setup-python` + `pip install pytest` + a suite whose
@@ -842,36 +838,12 @@ Full rationale: `docs/decisions/records/ci/required-job-step-execution-markers.m
### `docs-reminder` job (non-blocking, PR-only — in `pr-checks.yml`)
Two lightweight nudges, both `::warning::`-only. Neither can fail the build — they are reminders,
not gates; prose-doc gates get gamed with token edits.
**1. The parity-doc reminder** enforces the CLAUDE.md "docs-update is part of done" rule for the
A lightweight nudge that enforces the CLAUDE.md "docs-update is part of done" rule for the
one case that's easy to forget and easy to detect: a PR that touches a SPA screen
(`web/src/screens/*.tsx`) or `ErsatzTV/LegacyUiRedirects.cs` but **does not** update
`docs/blazor-route-parity.md`. It diffs the PR against its base branch and emits a
`::warning::` annotation.
**2. The session-narrative reminder** (ersatztv#784) runs `scripts/check-doc-narrative.py --diff`
over the lines this PR **adds** to `docs/**/*.md` (minus `docs/decisions/**`, exempt wholesale) and
root-level `*.md`, flagging text that narrates the document's own revision history —
`docs.no-session-narrative`. It is advisory **by design and permanently**: a narrative detector is a
string predicate over prose, the class `docs/defect-shapes-773.md` §4 argues must never be
load-bearing, so the script exits 0 on every path including a bad argument or an unresolvable base
ref. It does NOT rest on prose alone, and it does not rest wholly on tests either: the record's
COVERAGE BOUNDARY names which clauses carry a mutation proof and which are defensive and unproven
(the unhandled-exception arm among them). Every argument shape is asserted per shape in
`scripts/tests/test_check_doc_narrative.py` rather than only in prose. When it cannot resolve the
base it prints `SCANNED NOTHING` instead of a clean-looking line, because a silent zero-file scan is
indistinguishable from a clean one — the same failure `ci.required-job-step-execution-markers`
exists for.
Because this step needs an interpreter, the job DOES carry `actions/setup-python` — the one
exception to the no-setup-actions note below, since `python3` is not guaranteed on the bare `small`
lane. Both it and the script step carry `continue-on-error: true`: a script that exits 0 does not by
itself keep the JOB green, and a setup-action download failure would redden an advisory check just
as effectively as a hit would.
The job deliberately has **no** `setup-dotnet`/`setup-node` (and
`::warning::` annotation (never fails the build — it's a reminder, not a gate; prose-doc
gates get gamed with token edits). Deliberately has **no** `setup-dotnet`/`setup-node` (and
thus no `actions/cache`), so it can't hit the cache-save hangs seen on the VM-127 runner
(server-management#570). It does not cover the remaining doc obligations in the CLAUDE.md table
(domain-model, spa-conventions) — those stay on the author. (The API contract is mechanized by the
@@ -932,14 +904,14 @@ this job (`prove-fix.sh` exits 5 on a harness/git failure or a signal, and the j
error), so what the exit-code discipline buys is the other way round — a **green** here means a claim
was witnessed, never that a run was cancelled or broke.
### `script-tests` job (`Script lint and tests (ruff + pytest)`, PR-only — in `pr-checks.yml`)
### `script-tests` job (`Script tests (pytest)`, PR-only — in `pr-checks.yml`)
> Reddens the run on failure, but like the other `pr-checks.yml` gates it is **not** one of the
> three required status checks on `main` (`Build & test (.NET)`, `EF migration integrity`,
> `review-verdict/h10`). Promoting it to required is a branch-protection change, tracked separately.
Runs the repository's Python test suite: `PYTHONPATH=. python3 -m pytest scripts/tests -q`
(773 tests at `706674272`, ~4.5 min; the suite grows fast — it was ~190 tests / ~10s when this job landed in #631 — so treat the figure as a dated snapshot, not a budget). It covers the decision-corpus parser/validator/catalog builder, the ersatztv#610
(~190 tests at time of writing, ~10s; the suite grows, so treat the figure as indicative). It covers the decision-corpus parser/validator/catalog builder, the ersatztv#610
migration-equivalence harness, the merge-consent exemption logic and the ersatztv#622 review-verdict
poster.
@@ -960,33 +932,7 @@ name keeps a real failure unambiguous.
input set spans more than one directory — `test_post_review_verdict.py` and
`test_merge_consent_exemption.py` execute the real `scripts/post-review-verdict.sh` and
`.claude/hooks/pretooluse-merge-consent.sh` — so a `scripts/**` filter would silently miss a
`.claude/hooks/**` edit. The reason is the input set, not the cost: the suite was ~10s when that was
decided and is now ~4.5 min, and it would still be wrong to filter on `scripts/**`.
**It also lints (ersatztv#780).** Early in the job it installs a **pinned** `ruff==0.12.11` and runs
`ruff check` and `ruff format --check` against the repo-root `ruff.toml`. Five things are deliberate:
- The config is **committed**. Without it ruff falls back to whatever `~/.config/ruff/ruff.toml` the
operator's machine has, so a second machine lints this repo differently or not at all.
- The version is **pinned** — an unpinned install makes the verdict a function of when the job ran,
the same divergence one layer up, and the same argument as the `jq` pin below. `pytest`/`pyyaml`
stay unpinned on purpose: a pytest release does not add assertions to your suite, a ruff release
adds rules to your lint.
- Lint runs **before the jq preflight**, and after the `git` one. `Preflight jq version` is a hard
`--expect` tripwire; a lint sitting behind it goes dark for as long as the jq contract is broken,
under a red that says "jq". `Preflight external tools` stays ahead, because the lint steps consume
`git` — without it a missing git reaches them as an empty population and they blame the glob.
- Neither step is `ruff check .`. Both pass an **explicit population** from
`git ls-files -z '*.py' '*.pyi' '*.ipynb'` with `--no-force-exclude`, and fail if that list is
empty. Discovery-based invocation is silently emptied by an `exclude` in the right config scope —
top level empties both commands, `[lint]` empties `check`, `[format]` empties `format --check`
(and `[format]` is where an appended line lands) — and
`ruff check .` over zero files exits **0** with only a stderr warning, so the failure mode is a
green gate. The measured matrix is in `ci.python-lint-ruff-config-committed`.
- `RUF100` is selected, so a `# noqa` that no longer matches anything is itself a finding.
`pyright` is not gated; the reasoning and the exemption list are in
`ci.python-lint-ruff-config-committed`.
`.claude/hooks/**` edit. At ~10s, a filter buys nothing but drift.
**Dependencies: `pytest` and `pyyaml`** — the complete third-party set across `scripts/`, established
by an AST import scan rather than by reading the files that looked relevant. PyYAML does **not**
@@ -1580,133 +1526,6 @@ a follow-up commit. That is the same two-step below, just re-run after the rebas
avoid it entirely is to **land a toolchain-image change on its own, before** the work that consumes
it, so the consuming branch never carries the `docker/ci` commit through a rebase.
### When the pinned tag disappears
⚠️ **An immutable PIN is a promise about what we consume, not about what the registry keeps.** It
means the jobs never follow a floating tag like `:latest`, so a fresh toolchain push cannot change
what today's CI runs. It does not promise the tag will still EXIST — nor, strictly, that the tag's
content is frozen: `ci-image.yml` tags `git rev-parse --short HEAD`, so a `workflow_dispatch` or a
weekly `no-cache` run at the same HEAD republishes that same `:<sha>` from a rebuilt image. Those are
three different claims, and existence is the one that is not ours to make: the registry belongs to
server-management, and an owner-level Gitea *package cleanup rule* there (`keep_count` 15,
`remove_days` 1, `remove_pattern` `.*`, and a `keep_pattern` that no 7-hex sha can match) deletes any
sha tag once 15 newer versions of the package exist. `ci-image.yml` publishes a new `:<sha>` weekly
and on every push touching `docker/ci/**` or the workflow file, while the pin only moves when a human
bumps it — so a pin ages toward eviction on its own. That is what happened between 2026-08-11 and
2026-08-13 (ersatztv#772): the tag vanished, and every `container:` job — **both required contexts
included** — died after 12s with
```
Error response from daemon: failed to resolve reference ".../ersatztv-ci:<pin>": not found
```
buried in each job's log. Nothing said "your toolchain image is gone", so the natural first reading
was "my diff broke the build", and that is where the review time went. The durable fix is
registry-side and is tracked in **timothy/server-management#842**; until it lands, assume any pin
older than a couple of weeks can evaporate.
**How firm that cause is, since it decides whether you go looking further.** The rule and its nightly
execution are directly observed; the specific deletion is not, because Gitea hard-deletes package
versions with no audit row. What ties them is the same rule's fingerprint on the sibling `ersatztv`
package — every `:<sha>` older than the 15-slot window gone, every `keep_pattern` tag kept back to
`26.3.1`. Reproduce both halves on the Gitea host (LXC 119, `192.168.1.95`):
```bash
# on the Gitea host: the rule itself
sqlite3 /var/lib/gitea/data/gitea.db 'select * from package_cleanup_rule;'
# from anywhere: that the cleanup task is scheduled and has been running (schedule/prev/exec_times)
curl -s -u user:pass 'http://192.168.1.95:3000/api/v1/admin/cron?limit=50' \
| jq '.[] | select(.name == "cleanup_packages")'
```
(Do **not** reach for `journalctl -u gitea | grep ExecuteCleanupRules` — that identifier reaches the
log only via slow-query warnings, so an empty grep on a healthy host would read as "the rule never
ran", which is the inverse of what it means.)
If a pin disappears again *after* #842 changes that rule, treat this cause as refuted rather than
re-applying it — something else is deleting tags.
**Detection.** `docker-build.yml::toolchain-preflight` (`scripts/ci-toolchain-image-resolves.sh`)
resolves every pin in `docker-build.yml` against the registry on every run and fails with a message
that names the tag. It is container-free by necessity — a job consuming the missing image could not
run to report it — and deliberately **not** a `needs:` of the five jobs it diagnoses: the container
jobs already fail fast, so gating them would tax every green run to speed up a rare red one.
**Everything it cannot establish is a FAILURE, not a warning**, and the arms are worth knowing
because they send you to different places:
| Answer | Job | Message says |
|---|---|---|
| HTTP 200 with a manifest body | green | resolves |
| HTTP 404 | **red** | `IS GONE` — rebuild the tag (recovery above) |
| HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) |
| 401 / 403 | **red** | the credentials were rejected — fix the secrets |
| anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin |
| `ETV_REGISTRY_AUTH` unset, malformed, or either half empty | **red**, before any query | an absent secret interpolates to `":"`, which is not a credential |
The last two rows are the ones worth defending, because warning on them and exiting 0 is the natural
way to write this check and it is wrong: a missing `curl`, a moved registry and a DNS change all land
there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after. The
unknown arm retries first (`ETV_CI_ATTEMPTS`, default 3, `ETV_CI_RETRY_SECONDS` apart) so an ordinary
registry blip does not redden a PR — that pause is what makes failing on unknown affordable, and
shortening it silently trades this guard for flake.
**It is not a `needs:` of anything, but it is not consequence-free either.** The merge-consent hook
reads the PR's **combined** status and denies on a non-`success` combined state (a `skipped` context
counts as green, ersatztv#593; an advisory red does not, ersatztv#598), so a red preflight blocks the
merge exactly like any other red job. "Advisory" would be the wrong word for
it — what it does not do is *skip* the jobs it diagnoses.
**Recovery, without needing CI to be healthy.** The tag names a commit, and that commit still builds
the same image, so the fastest fix is to republish the *same* tag by hand — no PR, no pin bump, no
green CI required, and every open branch recovers at once. Run this on a host with docker and this
registry in `insecure-registries` (bumblebee `192.168.1.99` or jazz `192.168.1.29`):
```bash
repo=$(pwd) # keep the CURRENT checkout: the pin commit predates
# the preflight script and the verify step below
pin=$(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
git worktree add /tmp/etv-toolchain "$pin" # the pin IS the commit's short sha
cd /tmp/etv-toolchain
# The registry is HTTP-only and BuildKit does NOT inherit the daemon's insecure-registries, so the
# `docker-container` driver (anything created by `docker buildx create`) will try HTTPS and fail.
# Either build on the default `docker` driver — `docker buildx use default` — or give the container
# driver the same inline config ci-image.yml passes it:
# [registry."192.168.1.95:3000"]
# http = true
prev_builder=$(docker buildx inspect 2>/dev/null | awk '/^Name:/{print $2; exit}')
docker buildx use default # needs the containerd image store to --push;
# both named hosts have it (checked 2026-08-22)
docker login 192.168.1.95:3000 -u timothy
docker buildx build --platform linux/amd64 --provenance=false \
-f docker/ci/Dockerfile -t "192.168.1.95:3000/timothy/ersatztv-ci:$pin" --push .
cd "$repo" && git worktree remove /tmp/etv-toolchain
[ -n "$prev_builder" ] && docker buildx use "$prev_builder" # leave the builder as you found it
```
Then confirm the tag resolves before re-running anything — the preflight script does exactly this
check and takes no arguments. Run it from the CURRENT checkout, not the pin worktree, which is why
`$repo` is kept above:
```bash
ETV_REGISTRY_AUTH=user:pass scripts/ci-toolchain-image-resolves.sh
```
**What this rebuild does and does not restore.** It restores a *working* toolchain at that tag, built
from that commit's `docker/ci` — not a bit-identical copy of what was deleted: the base image tags
and the apt/NodeSource packages the Dockerfile pulls are mutable, so a rebuild picks up whatever they
point at today. That is the same exposure the weekly `no-cache` cron has by design. Prefer this over
the two-step above whenever the pin is *missing* rather than *stale*: the two-step exists to move the
pin to a NEW image, and running it here would leave the repo pinning a different sha for no reason.
The push path was exercised against this registry on 2026-08-22 — a throwaway `docker push` of a
13 MB image to `timothy/etv-772-recovery-probe:probe1` from bumblebee, `HEAD /v2/.../manifests/probe1`
→ `200`, then `DELETE /api/v1/packages/timothy/container/etv-772-recovery-probe/probe1` → `204` and
the manifest read back `404`. Re-run that shape against a scratch package name to re-establish it;
what it establishes is the auth + HTTP-registry push path, not the toolchain build itself.
**Bumping the pin is enforced, not remembered.** The `ci-image-pin` job (blocking, PR-only; defined
in `pr-checks.yml`, but it greps `docker-build.yml` where the pins live) fails if
`docker-build.yml`'s pin isn't the short sha of the last commit to touch `docker/ci/**` or
+4 -12
View File
@@ -39,7 +39,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP, which is worse than absent because it reads in review as a constraint. That version precondition NO LONGER HOLDS: this instance was upgraded 1.25.4 -> 1.27.1 on 2026-08-05. What has NOT changed is that the consequence is unverified — whether `permissions:` is honored here, and what this instance's default Actions token permission is, were both left UNPROBED (there is still no API surface: `/api/v1/settings/actions` 404s at 1.27.1). Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation. | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) |
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](records/ci/batch-pushes-no-cancel-route.md) |
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](records/ci/build-once-rejected.md) |
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. THE COMBINED COMMIT-STATUS ENDPOINT CANNOT EXPRESS THIS: `GET /repos/{o}/{r}/commits/{sha}/status` has states `success`/`failure`/`pending`/`error` and NO `cancelled`, so it reports a cancelled job as `failure`. Anything polling that endpoint — which is what a CI monitor naturally polls, because it is the per-sha view the merge gate reads — must resolve the job-level `conclusion` via `actions/runs/{id}/jobs` before reporting a red. | 2026-07-21 | [link](records/ci/cancelled-is-not-a-verdict.md) |
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](records/ci/cancelled-is-not-a-verdict.md) |
| `ci.decisions-edit-trailer` | The body-diff exemption is armed by an affirmative `Decisions-Edit:` **git trailer** (`yes`/`true`/`1`, case-insensitive, read with `unfold`) on some NON-MERGE commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (`no`) does not arm it, the retired `[decisions-edit]` substring arms nothing (the validator emits a `::warning::` nudge when it sees one without a trailer), and a git error leaves the guard ON. | 2026-07-25 | [link](records/ci/decisions-edit-trailer.md) |
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](records/ci/decisions-lifecycle-flake.md) |
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](records/ci/docs-only-detect-shallow-safe.md) |
@@ -56,11 +56,10 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) |
| `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) |
| `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) |
| `ci.python-lint-ruff-config-committed` | The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. | 2026-08-21 | [link](records/ci/python-lint-ruff-config-committed.md) |
| `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key>` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always <keys> --gated <keys>`, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) |
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) |
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](records/ci/runner-placement.md) |
| `ci.script-tests-job` | The `scripts/tests/` pytest suite runs on every PR as a dedicated `script-tests` job in `pr-checks.yml` (`runs-on: small`, `setup-python` + `pip install pytest pyyaml`, `PYTHONPATH=. python3 -m pytest scripts/tests -q`; since #780 it also runs a pinned ruff over a `git ls-files` population first), unconditionally rather than behind a `scripts/**` path filter, and **never as a step inside `decisions-guard`** — a job whose reds a standing rule instructs sessions to ignore must never host a gate whose reds are real. Any new CI gate must be reachable by a failure that is unambiguously attributable to it. | 2026-07-26 | [link](records/ci/script-tests-job.md) |
| `ci.script-tests-job` | The `scripts/tests/` pytest suite runs on every PR as a dedicated `script-tests` job in `pr-checks.yml` (`runs-on: small`, `setup-python` + `pip install pytest`, `PYTHONPATH=. python3 -m pytest scripts/tests -q`), unconditionally rather than behind a `scripts/**` path filter, and **never as a step inside `decisions-guard`** — a job whose reds a standing rule instructs sessions to ignore must never host a gate whose reds are real. Any new CI gate must be reachable by a failure that is unambiguously attributable to it. | 2026-07-26 | [link](records/ci/script-tests-job.md) |
| `ci.shared-pr-file-enumeration` | A PR's complete set of changed file paths is computed by exactly one implementation, `scripts/pr-changed-files.sh`, called by both `.claude/hooks/pretooluse-merge-consent.sh` (advisory — a failure falls through to a human prompt) and `.gitea/workflows/review-verdict.yml` (enforced — a failure must fail closed, because a match here posts the branch-protection-required `review-verdict/h10` status with nobody in the loop). The script owns exhaustiveness (pagination, rename/path validation, head-sha binding, base-ref binding — see `ci.exemption-provenance` — and base-TIP binding, #707: the ref answers "did this PR RETARGET", the tip answers "did the base ADVANCE mid-enumeration", and only the second can see `/pulls/{n}/files` recomputing each offset-paged page against a moved base and dropping a path out of an already-consumed range; both ends of the window are bound, and an advance BEFORE the window is deliberately not an error, or ordinary churn on `main` would fail every open PR) and returns exit 0 only for a verified-complete list; it does NOT classify paths — each caller keeps its own docs-only allow-list, and the two allow-lists differ on purpose and stay separate. | 2026-07-26 | [link](records/ci/shared-pr-file-enumeration.md) |
| `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](records/ci/small-lane-git-only.md) |
| `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](records/ci/ui-e2e-harness.md) |
@@ -81,7 +80,6 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `docs.decision-one-file-per-record` | Each decision record is its own file at `docs/decisions/records/<area>/<topic>.md` (archived ones at `docs/decisions/archive/<area>/<topic>.md`) with YAML frontmatter; the filename IS the key, so one-active-record-per-key is a filesystem property rather than a validator check, and supersession is a `git mv`. | 2026-07-25 | [link](records/docs/decision-one-file-per-record.md) |
| `docs.decision-optional-provenance` | Decision records gain two OPTIONAL fields — `stale-after: YYYY-MM-DD` on the metadata line and a `**Sources:**` line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format. | 2026-07-25 | [link](records/docs/decision-optional-provenance.md) |
| `docs.frontmatter-pyyaml-crosscheck` | `decisions_validate.py` runs `pyyaml_frontmatter_faults()` over every record-wing file: it loads the frontmatter with PyYAML and reports an ERROR when PyYAML rejects the document OR when any key's value differs from what the dependency-free `dl._read_frontmatter` read. PyYAML is the WRITER of these files (`migrate_decisions_split.render_record` emits them with `yaml.safe_dump`), so on any disagreement PyYAML is authoritative and the defect is in the FILE, not in either parser. The check is strictly additive: when PyYAML is not importable it is SKIPPED and `main()` says so with a `::notice::`, never silently — the read path stays dependency-free because `decisions-guard`, the Husky hooks and contributor machines install nothing. The comparison has exactly ONE implementation, called by both the validator and `test_frontmatter_reader_matches_pyyaml_on_every_real_record`, so the suite and the tool cannot drift on what "matches PyYAML" means. | 2026-08-04 | [link](records/docs/frontmatter-pyyaml-crosscheck.md) |
| `docs.no-session-narrative` | Every durable artifact — an in-repo `docs/` page, a skill, a README, a code comment, an Obsidian vault page — records the END STATE. The path to that end state goes in the commit message, the Gitea issue, or the issue's `## Closing record`; it does not go in the artifact. Concretely: **a review finding is answered in the commit message, and only the corrected claim enters the doc.** Naming the destination is load-bearing — "do not write it in the doc" with no home loses the knowledge, and this repo has the inverse failure on record too (#542, where a pruned narrative turned out to be the only copy). THE TEST IS WHO BENEFITS: if only the author's timeline explains why a sentence is there, it is narrative and belongs in the commit; if a reader who never saw the session would act differently knowing it, it is a finding and stays. Session narrative reads as: first person or session chronology ("I initially thought", "an earlier draft counted", "my first attempt returned 0"), a correction of a belief the reader never held ("this was wrong, actually X" where only X matters), relative time ("earlier today", "currently investigating"), or a blow-by-blow diagnosis standing in place of the conclusion. THE CARVE-OUT, which must be stated or the rule gets over-applied — reader-facing history that must survive: a decision record's `supersedes`/`superseded-by`; a dated measurement or an explicitly stated snapshot boundary; a TESTED-AND-REJECTED negative result, kept so nobody re-proposes it on plausibility; the *why* behind a non-obvious choice; and a trap together with its consequence. `docs/decisions/records/**` and `docs/decisions/archive/**` are exempt WHOLESALE: a record narrating how a rule was got wrong is carrying the rationale it exists to carry. ENFORCEMENT IS ADVISORY ONLY — `scripts/check-doc-narrative.py`, run non-blocking from the `docs-reminder` job over ADDED lines. It is a string predicate over prose and may never become a blocking gate. | 2026-08-21 | [link](records/docs/no-session-narrative.md) |
| `docs.record-wing-parse-guard` | `decisions_validate.py` asserts, per PATH, that every `*.md` under `docs/decisions/records/**` and `docs/decisions/archive/**` parses to exactly one record carrying a `key` — an ERROR, not a warning, since a file in the record wings that is not a record is a mistake by definition. A file sitting DIRECTLY in `archive/` is exempt only when it actually looks like a #610 stripped index — exactly one keyless record with a known generated heading — never merely by living there. The one other exemption, `archive/README.md`, is by exact RELATIVE PATH; nothing is ever exempt by BASENAME, since that would exempt the same filename in the active wing too. `_read_frontmatter` is deliberately NOT extended to accept YAML block scalars: every record value goes on ONE line, and the structural check is what makes that limitation loud instead of silent. | 2026-07-26 | [link](records/docs/record-wing-parse-guard.md) |
| `docs.tracker-comment-retrofit` | When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent. | 2026-07-21 | [link](records/docs/tracker-comment-retrofit.md) |
| `ffmpeg.external-logo-graphics-engine` | External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. | 2026-07-20 | [link](records/ffmpeg/external-logo-graphics-engine.md) |
@@ -102,14 +100,12 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `mcp.server-foundation` | `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`. | 2026-07-20 | [link](records/mcp/server-foundation.md) |
| `mcp.tool-schema-openapi-parity` | Every POST/PUT/PATCH tool in `ToolCatalog` declares exactly the request-body properties its endpoint accepts, each with a matching type, and EVERY tool (read and write) declares exactly its endpoint's query parameters, both asserted against the generated `ErsatzTV/wwwroot/openapi/v1.json` (linked into `ErsatzTV.Mcp.Tests`) by `Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields` and `Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`. A field the endpoint accepts but the tool omits is a DEFECT, not a deferral: on the full-replace tools (channel update, schedule update, custom-order) the omission is silently applied as a clear. The write tools are NOT uniformly full-replace — add-collection-items is additive, and several leave an omitted field unchanged — so each tool description states its own semantics. An omitted query parameter is UNREACHABLE, not merely undocumented, because `ToolArgumentValidator` rejects undeclared arguments. | 2026-08-06 | [link](records/mcp/tool-schema-openapi-parity.md) |
| `media.lastscan-null-boundary` | A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup. | 2026-07-18 | [link](records/media/lastscan-null-boundary.md) |
| `media.nullable-primitive-collection-mutation` | Code that reads a nullable EF PRIMITIVE COLLECTION guards it at the read site — `Optional(x).Flatten()` hoisted into a local — and NEVER writes the guard back onto the entity with `??= []`. Such a collection is stored WHOLE, in ONE COLUMN, so unlike the navigation collections that the same `??= []` idiom guards harmlessly all over this repo, the property IS the column value: assigning it on a TRACKED entity flips the entry to `Modified` and the next `SaveChanges` persists `[]` over what the database held as `NULL`. The population is derived from the MODEL, not from a grep of the domain classes, and is currently EIGHT columns: `SongMetadata.Artists`/`AlbumArtists` (EF-native primitive collections, no explicit configuration, stored as a JSON array) plus the six value-converted collections registered in `ErsatzTV.Infrastructure/Data/Configurations``ProgramScheduleAlternate` and `PlayoutTemplate` each carrying `DaysOfMonth`, `MonthsOfYear` (`IntCollectionValueConverter`, COMMA-SEPARATED text, not JSON) and `DaysOfWeek` (`EnumCollectionJsonValueConverter`, JSON). The shared property that matters is not the serialization format but that the collection is ONE SCALAR COLUMN, so do not reason from EF-native primitive-collection behaviour when standing in front of a converter. Only the `SongMetadata` pair is left NULL in practice, because `FallbackMetadataProvider` never assigns it. No site applies `??=` to any of the six (`grep -rn 'DaysOfMonth ??=\\|MonthsOfYear ??=\\|DaysOfWeek ??=' --include='*.cs' .` returns 0 at time of writing), so THIS defect has no instance there; whether a null can reach one of them at runtime is a SEPARATE question this record does not answer and does not assert — the API request records normalize with `?? []`, but `ReplacePlayoutAlternateScheduleItemsHandler` and `ReplacePlayoutTemplateItemsHandler` assign the command value straight onto the entity, so a non-API caller is UNVERIFIED (#823). A grep for `IList<string>` finds only two of the eight and an enum collection none of them — so a sweep follows the FIELD via the model configuration, not the file: the mutation and every read it was protecting must move together, or removing the assignment trades a silent write for a live throw. The read FORM decides which throw, and BOTH occur in this one PR: `foreach` over a null collection throws `NullReferenceException` (the two Lucene reads — measured), while `string.Join`/`Enumerable.ToList` on a null SOURCE throw `ArgumentNullException` (the two Elastic reads, and the `#671` mapper sites). Neither exception type alone characterises the class, so neither grep finds it — which is the actual reason a sweep must follow the FIELD rather than an exception name. Whether today's callers happen to be `AsNoTracking` is NOT the safety argument and must not be written down as one: it is a property of the current callers, not of the code holding the entity, and it is what #691 recorded as a loaded gun. | 2026-08-22 | [link](records/media/nullable-primitive-collection-mutation.md) |
| `media.remote-stream-probe` | `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle. | 2026-07-19 | [link](records/media/remote-stream-probe.md) |
| `media.remote-stream-probe-externaljson` | External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows. | 2026-07-20 | [link](records/media/remote-stream-probe-externaljson.md) |
| `media.source-mgmt-write-api` | Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under `/app/libraries/*`, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored `apiKey`, and each PUT-replace family's identity contract is documented per-family (not assumed uniform). | 2026-07-11 | [link](records/media/source-mgmt-write-api.md) |
| `process.bom-format-detection-recipe` | Before any push touching `.cs`, detect BOMs with the `od -A n -t x1 -N 3` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh. NOT `xxd`: it ships with vim and is absent on plain Linux hosts including this repo's CI runner, where the substitution yields empty, never matches, and the check reports all-clean — the same all-clean-detector failure this record was written about, in the detector it prescribed. | 2026-07-21 | [link](records/process/bom-format-detection-recipe.md) |
| `process.branch-off-feature-branch` | To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building. | 2026-07-21 | [link](records/process/branch-off-feature-branch.md) |
| `process.build-concurrency-limits` | Run at most 34 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. | 2026-07-21 | [link](records/process/build-concurrency-limits.md) |
| `process.check-and-use-pins-a-version` | Where a CHECK authorizes an ACTION over state that can change in between, the two are bound to ONE version of that state. Binding alone is not enough and is the half that keeps being skipped: a snapshot nothing re-validates is not pinned, it is a stale read wearing a version number. Three substrates, three mechanisms, and they are the SAME rule — in-process, a compare-exchange claim taken by the caller, never a `Volatile.Read` in one place and an `Interlocked` in another (`ffmpeg.work-ahead-slot-atomic`); over our own HTTP API, RFC 7232 `If-Match`/ETag, with the force-write path named explicitly rather than left implicit (`concurrency.ifmatch-rfc7232`, `concurrency.force-write-non-ifmatch`); against a remote service, a full commit sha, an image digest or a monotonic event count re-read immediately before the write. Prefer true compare-and-set where the server offers it. Where it does not — Gitea's commit-status API has no ETag, no If-Match and no expected-previous-state — the ceiling is READ-COMPARE-REFUSE: re-read the identifier immediately before the write and FAIL CLOSED on any movement, which narrows the window to one round trip and makes the loss observable instead of silent. A residual that cannot be closed is STATED in the code and carried in `docs/remote-state-inventory.md` as `UNSAFE-KNOWN` with the reason it is tolerable; "noticed" is not "accepted". Two identifier traps are load-bearing here: compare the FULL sha, never a 7-char prefix, and compare a base BRANCH REF rather than its tip sha, because the tip moves on every unrelated merge and comparing it deadlocks every open PR. Finally, and this is the failure #778 actually found: a mitigation that lives OUTSIDE the code relying on it — branch protection, a required status context, a server-side refusal — must be VERIFIED at the point of use, not asserted in a comment or in the reason string a human reads. A dated claim about configuration is not a check, and it is worse than no claim, because it talks the next reader out of looking. | 2026-08-16 | [link](records/process/check-and-use-pins-a-version.md) |
| `process.codex-cheap-worker-launch` | For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings. | 2026-07-21 | [link](records/process/codex-cheap-worker-launch.md) |
| `process.consistency-fix-new-code-scrutiny` | Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount. | 2026-07-21 | [link](records/process/consistency-fix-new-code-scrutiny.md) |
| `process.enumerate-workaround-behaviors-before-deleting` | When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first. | 2026-07-21 | [link](records/process/enumerate-workaround-behaviors-before-deleting.md) |
@@ -137,7 +133,6 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `release.promotion-floating-prod` | Prod tracks the floating `:prod` image reference; a tag build's immutable `:<version>` image is scanned first, then promotion happens via a separate manual `DeployStack`, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. | 2026-07-13 | [link](records/release/promotion-floating-prod.md) |
| `release.review-verdict-gate` | A PR may not merge until a `Review-verdict: <MERGEABLE\|APPROVED\|BLOCKED\|NOT-MERGEABLE> @ <head-sha>` comment references the PR's current head sha (short-sha prefix match against the verdict's OWN `@ <sha>` field, marker at COLUMN 0 (no indent, so indented code blocks cannot self-approve), whole-word verdict token, fenced code blocks stripped with markdown fence-length semantics, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). The grammar lives in ONE tested place, `scripts/check-review-verdict.sh`#629 found three false-opens that survived because it was implemented inline and untested while this record described stricter behaviour than the code had. | 2026-07-12 | [link](records/release/review-verdict-gate.md) |
| `release.verdict-status-check` | The H10 review verdict is written as a `review-verdict/h10` Gitea **commit status** on the exact reviewed sha by `scripts/post-review-verdict.sh`, and that context is a REQUIRED status check on `main`. Because a status belongs to one sha, a later commit cannot inherit it, so Gitea's own `merge_when_checks_succeed` refuses to merge a head no one reviewed. The PreToolUse hook additionally refuses to SCHEDULE an auto-merge unless that status is already green on head. A `pull_request_target` workflow auto-passes the two exempt classes (Renovate-authored, docs-only) unless the PR touches a protected path (`.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/`, `docker/ci/`). This extends — does not supersede — `release.review-verdict-gate` (#303 H10), whose comment convention remains the human-readable artifact and the hook's condition (c). | 2026-07-25 | [link](records/release/verdict-status-check.md) |
| `release.verdict-writes-status-before-comment` | `scripts/post-review-verdict.sh` writes the sha-bound `review-verdict/h10` commit status FIRST and the human-readable `Review-verdict:` comment SECOND. Every refusal path still refuses (fail-closed, unchanged) and exits non-zero, and none of them may leave a verdict comment behind. An orphaned comment is therefore PREVENTED rather than tolerated. If the comment write fails after the status was written, that is an error too, but it degrades to an `ask` at the merge gate rather than to an apparent grant. | 2026-08-22 | [link](records/release/verdict-writes-status-before-comment.md) |
| `rulebuilder.relative-date-macros` | The visual rule builder's `inLast`/`notInLast` date operators compile to/parse from the pre-existing `CustomMultiFieldQueryParser` macros `released_inthelast`/`released_notinthelast` and `added_inthelast`/`added_notinthelast`, value form `"<n> day\|week\|month\|year"`; there is no backend change. | 2026-07-23 | [link](records/rulebuilder/relative-date-macros.md) |
| `scan.collections-scan-status` | `GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout. | 2026-07-12 | [link](records/scan/collections-scan-status.md) |
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](records/scan/getoraddfolder-db-lookup.md) |
@@ -196,17 +191,14 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `spa.topbar-primary-action` | The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button. | 2026-07-12 | [link](records/spa/topbar-primary-action.md) |
| `spa.yaml-validator-textarea` | The YAML playout validator takes pasted YAML via a `<textarea>`, not a server-side file path, since the SPA has no filesystem access. | 2026-07-09 | [link](records/spa/yaml-validator-textarea.md) |
| `startup.parallel-orientation` | A fresh session runs two concurrent tracks at startup — Orientation (`AGENTS.md`/`CLAUDE.md``docs/README.md` task-signal map → the active decisions catalog `docs/decisions/README.md`) and, only when no issue is named, Selection (`scripts/select-queue.sh N`, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state. | 2026-07-21 | [link](records/startup/parallel-orientation.md) |
| `testing.deny-path-at-production-config-value` | Where behaviour is gated by a configuration value, an environment variable or a credential, the test matrix covers every value the surface will actually meet — the setting ABSENT, the setting at its PRODUCTION value, and each explicit opt-out — and it asserts the DENY branch, not only the allow branch. A fixture that OMITS the field tests the default and nothing else, so a fail-open reachable only through the configured value stays invisible however many tests are green (#756: thirty of them were). Two corollaries carry most of the weight. FIRST, a hand-written test double that is HANDED the resolved flag proves the CONSUMER reacts to it and says nothing about the line that DERIVES it; if no test constructs the real provider, a mistyped configuration key or a flipped default is unobservable to the whole suite. SECOND, the dangerous cell is whichever one production occupies, which is not always the explicit one: when the shipped default IS the permissive branch the absent case is the production case (#280's null `Api:WriteKey`), and when the default is fail-closed the configured value is the one nothing has exercised. Enumerate the cells before deciding which to test; do not infer the risky one from which is easier to write. This rule is NOT mechanically enforced and deliberately so — deciding whether a given test used the production value is a string predicate over test source, the class this repo has withdrawn twice. | 2026-08-21 | [link](records/testing/deny-path-at-production-config-value.md) |
| `testing.e2e-cleanup-scope-by-pid` | An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. | 2026-07-25 | [link](records/testing/e2e-cleanup-scope-by-pid.md) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The *readiness-probe hang* this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.) | 2026-07-21 | [link](records/testing/e2e-local-fresh-config-dir.md) |
| `testing.enumerating-guard-identity-not-position` | A guard that cross-checks a hand-reviewed registry against call sites discovered across the whole repo must key each entry on properties INTRINSIC to the site — file, kind, and the value source text — and never on its absolute line or column. A registry keyed on position is a function of every other file in the repo, so a branch that never touches the guard can invalidate it; and because each PR is green against its own base, that failure is structurally invisible pre-merge and lands on `main` after review and after the merge gate. Dropping the position keeps every mutation the guard exists for — a NEW site, a REMOVED site and a CHANGED value each still fail, since each changes the identity multiset — and costs exactly ONE case, which must be stated rather than implied: a SAME-IDENTITY SUBSTITUTION within one file (delete a registered site, add a different unreviewed one with the same kind and value token, net-zero count) now passes. A REPORTED failure still prints the discovered line:column, because identity and diagnostics need not share a format. Comparison stays a MULTISET count rather than set membership, so two sites in one file sharing an identity must be discovered exactly that many times and a third occurrence still fails. A SCANNER test that asserts real AST positions against FIXED inline fixtures is the opposite case and keeps its line/column identity — it has no churn, because its input does not move. | 2026-07-27 | [link](records/testing/enumerating-guard-identity-not-position.md) |
| `testing.fix-ships-a-witnessed-red-test` | A commit claiming to fix something may carry a `Proves: <pytest selector>` trailer; when it does, `scripts/prove-fix.sh` must show that selector GREEN with the fix and RED with the code side reverted, and CI enforces it per-PR. The trailer is opt-in — an unproven commit is allowed — but a claimed proof that does not hold fails the build. | 2026-08-16 | [link](records/testing/fix-ships-a-witnessed-red-test.md) |
| `testing.full-replace-asserts-field-list` | Any path that writes a WHOLE entity or a WHOLE child collection — a PUT-replace handler, a hand-built request object, a test comparer standing in for one — derives its field list from the authoritative type and asserts SET EQUALITY against it, rather than enumerating the fields by hand. A hand-written list is correct on the day it is written and structurally unable to report the day it stops being: the field that drifts is the one nobody wrote a line for, so no amount of care in the existing lines can reach it. The failure is silent by construction — a full replace with a field omitted returns HTTP 200 and destroys that field's value (#754 drifted from a 28-property DTO by one and cleared it; the symptom arrived hours later as missing pixels). SECOND CLAUSE, separable from the first: where a replaced child row carries state keyed to its identity — progression, ordering, an enumerator position — the handler RECONCILES BY ID rather than delete-and-reinsert, because reinsertion silently resets state a client never asked to touch (#252: a schedule PUT reset fill-group progression; #500: a dedup fix became permanent data loss because the add filter and the remove filter used different keys, so the two halves must agree on the key). Delete-and-reinsert is acceptable ONLY where no such state exists, and that emptiness is a fact about today's schema that a later feature can silently invalidate — so record it where the handler is, dated, rather than leaving it to be re-derived. The canonical worked example is `ToolCatalogTests.Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields`, which reads the accepted fields from the generated OpenAPI document and compares both directions. ON THE SPA SIDE the same rule is enforced by the TYPE SYSTEM rather than by a test: a full-replace body is built as `Complete<T>` (`web/src/api/completeRequest.ts`), a mapped type that makes every member of a generated request type required, so a builder that omits one fails `npm run typecheck`. Annotate BOTH the API-wrapper parameter (so later callers inherit it) AND each construction site including every `.map` callback return type, because the excess-property check that catches a PHANTOM field fires only on a fresh literal in a contextually typed position and a generic `.map` callback is not one. Do not infer from "most builders already typecheck" that the gap is closed: a member is omittable exactly when it is absent from the schema `required` array in the ASP.NET-produced OpenAPI document, and two live cases (#807) sat unchecked inside a large majority of checked ones. | 2026-08-21 | [link](records/testing/full-replace-asserts-field-list.md) |
| `testing.guard-derives-population-from-source` | A guard that asserts a COMPLETENESS property enumerates its population from a machine-readable authoritative source the enum, the generated OpenAPI document, the parsed workflow YAML, the provider list — and asserts SET EQUALITY in BOTH directions against it. It may not narrow that population with a filter, a `Where`, a `grep` or an early `continue` before the assertion, because a filter cannot see the member that is MISSING: the member whose absence is the defect is precisely the one the predicate excludes. A hand-written literal list of members is the same defect in slower motion — a filter frozen at authoring time, correct on the day it was written and unable to report the day it stopped being. Two boundaries bound the rule rather than weaken it. FIRST, filtering to select the SUBJECT of a PER-MEMBER property is legitimate and is not this defect: the excluded members satisfy the property vacuously, so the filtered walk and the whole walk assert the same thing (`ToolCatalogTests.Every_Query_Parameter_Should_Be_A_Declared_Property` filters to tools that declare query parameters, and a tool declaring none has nothing to check). The defect is filtering the population before a COMPLETENESS claim, which is what makes an absent member unrepresentable (#757 filtered on `QueryParameters is {Count: > 0}` and so could not see a tool that should have declared one and did not). SECOND, a population of VALUES always has an external authoritative source and this rule applies directly; a population of SITES IN CODE has no such list, needs find-all-references tooling, and is tracked separately in #777 — do not stretch a set-equality assertion over it. Distinguish the guard SCOPE (which subsystems it covers — a reviewed policy choice, legitimately hand-written) from the guard POPULATION (the members inside that scope — always derived). When the scope itself MIRRORS an authoritative source, the mirror needs its own equality check or a dated staleness marker, or the guard is complete within a scope that has silently gone stale. The canonical worked example in this repo is `ToolCatalogTests.Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`; the canonical residual gap is `MARKED_JOBS` in `scripts/tests/test_ci_dropped_step_guard.py`. WHEN THE POPULATION IS FILES (#806), the authoritative source is the GIT INDEX and never a filesystem walk. A walk is not merely a weaker enumerator, it answers a question about the MACHINE rather than about the repo: it reports build output, generated shims and editor droppings, and it differs between CI and every checkout, so the same guard asserts a different population in each place. Derive with `git ls-files`, take direct children only unless a nested population is stated and wanted, and assert existence rather than filtering on it, because filtering is what makes a missing member unrepresentable. This is an instantiation and not a blanket rewrite: the question per guard remains whether it makes a COMPLETENESS claim over TRACKED files, and a walk that assembles a fixture or selects the SUBJECT of a per-member property stays a walk with its reason written down. | 2026-08-13 | [link](records/testing/guard-derives-population-from-source.md) |
| `testing.guard-ships-with-mutation-proof` | A guard is not considered tested because a test involving it passes. It ships with a MUTATION PROOF: remove or disarm THAT GUARD'S CLAUSE ALONE, and a NAMED test must go red. ONE NAMED EXCEPTION, with its limits, because the rule degenerates without it: where the guard IS a test (a checker enforcing a repo invariant, with no separate script behind it), disarming it makes it ABSENT rather than red, so the proof is the contrapositive — INTRODUCE THE DEFECT THE GUARD EXISTS TO CATCH into an isolated copy of the guarded artifact, and the named test must go red. That is a mutation of the guarded SYSTEM rather than of the assertion, and it is admissible ONLY for checker-guards and ONLY when the mutation was executed and witnessed. It is NOT a licence to grade an ordinary script-guard MUTATION for having a bad-input test: feeding a script an input its clause rejects is BEHAVIOUR-ONLY, which is what three rows were regraded for. A file-level grade under this exception covers the clause its cited case actually mutates, not every assertion that later lands in the same file. Three things this excludes, each of which has already shipped here as a green suite over a dead check. FIRST, a behavioural test — one that feeds the guard a good input and a bad input and checks it passes and fails — proves the guard REACTS, never that it is LOAD-BEARING; #685 had two guards on one condition where deleting either left the whole suite green while every behavioural test passed. SECOND, mutating the WHOLE FILE does not count (#510): a whole-file revert cannot show that a test reaches a particular clause, so the mutation must target the clause. THIRD, the guard being WIRED is not the guard RUNNING — #631's suite was invoked by no CI job, #751's step was dropped by the runner and the job reported success in 6s against a normal 14-17s, and #719's new logic was never connected to stdin. Every guard that DERIVES A POPULATION also carries an ANTI-VACUITY assertion, because the characteristic failure of a completeness check is reporting that it proved everything while its population was empty; a guard with no population has nothing for such an assertion to be about, and stating it universally reads as coverage the unproven rows do not have. Mechanical enforcement is possible for the BOOKKEEPING and not for the JUDGEMENT, and the split is the decision: `docs/guard-inventory.md` lists every guard file with its Kind, its Proof class (`MUTATION`/`BEHAVIOUR-ONLY`/`NONE`) and a `file::function` ref, and `scripts/tests/test_guard_inventory.py` derives the guard population from the GIT INDEX and the call sites (#806), asserts SET EQUALITY against the rows, and resolves every claimed ref to a real `def`. So a new guard cannot ship unclassified and a renamed test cannot leave a row silently claiming coverage. Whether a row claiming `MUTATION` is telling the truth is no longer left to review: `testing.mutation-claims-are-executed` (#790) requires each such row to carry a DECLARED clause mutation that is applied to an isolated copy of the repository on every run, with the row's own named test required to go red. | 2026-08-13 | [link](records/testing/guard-ships-with-mutation-proof.md) |
| `testing.guard-derives-population-from-source` | A guard that asserts a COMPLETENESS property enumerates its population from a machine-readable authoritative source — the enum, the generated OpenAPI document, the parsed workflow YAML, the provider list — and asserts SET EQUALITY in BOTH directions against it. It may not narrow that population with a filter, a `Where`, a `grep` or an early `continue` before the assertion, because a filter cannot see the member that is MISSING: the member whose absence is the defect is precisely the one the predicate excludes. A hand-written literal list of members is the same defect in slower motion — a filter frozen at authoring time, correct on the day it was written and unable to report the day it stopped being. Two boundaries bound the rule rather than weaken it. FIRST, filtering to select the SUBJECT of a PER-MEMBER property is legitimate and is not this defect: the excluded members satisfy the property vacuously, so the filtered walk and the whole walk assert the same thing (`ToolCatalogTests.Every_Query_Parameter_Should_Be_A_Declared_Property` filters to tools that declare query parameters, and a tool declaring none has nothing to check). The defect is filtering the population before a COMPLETENESS claim, which is what makes an absent member unrepresentable (#757 filtered on `QueryParameters is {Count: > 0}` and so could not see a tool that should have declared one and did not). SECOND, a population of VALUES always has an external authoritative source and this rule applies directly; a population of SITES IN CODE has no such list, needs find-all-references tooling, and is tracked separately in #777 — do not stretch a set-equality assertion over it. Distinguish the guard SCOPE (which subsystems it covers — a reviewed policy choice, legitimately hand-written) from the guard POPULATION (the members inside that scope — always derived). When the scope itself MIRRORS an authoritative source, the mirror needs its own equality check or a dated staleness marker, or the guard is complete within a scope that has silently gone stale. The canonical worked example in this repo is `ToolCatalogTests.Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`; the canonical residual gap is `MARKED_JOBS` in `scripts/tests/test_ci_dropped_step_guard.py`. | 2026-08-13 | [link](records/testing/guard-derives-population-from-source.md) |
| `testing.guard-ships-with-mutation-proof` | A guard is not considered tested because a test involving it passes. It ships with a MUTATION PROOF: remove or disarm THAT GUARD'S CLAUSE ALONE, and a NAMED test must go red. ONE NAMED EXCEPTION, with its limits, because the rule degenerates without it: where the guard IS a test (a checker enforcing a repo invariant, with no separate script behind it), disarming it makes it ABSENT rather than red, so the proof is the contrapositive — INTRODUCE THE DEFECT THE GUARD EXISTS TO CATCH into an isolated copy of the guarded artifact, and the named test must go red. That is a mutation of the guarded SYSTEM rather than of the assertion, and it is admissible ONLY for checker-guards and ONLY when the mutation was executed and witnessed. It is NOT a licence to grade an ordinary script-guard MUTATION for having a bad-input test: feeding a script an input its clause rejects is BEHAVIOUR-ONLY, which is what three rows were regraded for. A file-level grade under this exception covers the clause its cited case actually mutates, not every assertion that later lands in the same file; clause-level grading is tracked in #790. Three things this excludes, each of which has already shipped here as a green suite over a dead check. FIRST, a behavioural test — one that feeds the guard a good input and a bad input and checks it passes and fails — proves the guard REACTS, never that it is LOAD-BEARING; #685 had two guards on one condition where deleting either left the whole suite green while every behavioural test passed. SECOND, mutating the WHOLE FILE does not count (#510): a whole-file revert cannot show that a test reaches a particular clause, so the mutation must target the clause. THIRD, the guard being WIRED is not the guard RUNNING — #631's suite was invoked by no CI job, #751's step was dropped by the runner and the job reported success in 6s against a normal 14-17s, and #719's new logic was never connected to stdin. Every guard also carries an ANTI-VACUITY assertion, because the characteristic failure of a completeness check is reporting that it proved everything while its population was empty. Mechanical enforcement is possible for the BOOKKEEPING and not for the JUDGEMENT, and the split is the decision: `docs/guard-inventory.md` lists every guard file with its Kind, its Proof class (`MUTATION`/`BEHAVIOUR-ONLY`/`NONE`) and a `file::function` ref, and `scripts/tests/test_guard_inventory.py` derives the guard population from the filesystem and the call sites, asserts SET EQUALITY against the rows, and resolves every claimed ref to a real `def`. So a new guard cannot ship unclassified and a renamed test cannot leave a row silently claiming coverage. What stays with review, and is stated rather than papered over: nothing checks that a row claiming `MUTATION` is telling the truth. | 2026-08-13 | [link](records/testing/guard-ships-with-mutation-proof.md) |
| `testing.hook-reports-its-own-execution` | Every script in `.claude/hooks/` sources `scripts/hook-fire-log.sh` and calls `etv_hook_fire_begin <its-own-name> <label> <capture\|stream>` as its FIRST act, before anything reads stdin. Two records are appended per invocation — a `fire` record on entry and an `exit` record carrying the exit status and the decision — to a session-scoped JSONL log. THE DECISION IS READ FROM WHAT THE HOOK ACTUALLY EMITTED, never declared by the hook author: Claude Code hooks (`capture` mode) always exit 0 and communicate by PRINTING JSON, so their stdout is diverted and replayed, and the recorded decision is parsed from those bytes; git hooks (`stream` mode) decide by EXIT CODE and their stdout is live progress text a human is watching, so it is not diverted and the decision is the status. That split is not a tuning knob — capturing a slow pre-push hook's output would hold it back until the end and read as a hang, and inferring a git hook's decision from absent JSON would put the report back into the guessing business this record exists to end. The population is DERIVED from `.claude/hooks/*.sh` by `scripts/tests/test_hook_fire_log.py`, so a new hook is uninstrumented-and-red rather than silently unobserved, and the report lists every hook that EXISTS rather than every hook that appears in the log — a report built from the log alone can only show hooks that fired, which makes the never-fired hook, the one finding worth having, invisible. THE INSTRUMENTATION MUST BE INVISIBLE TO THE HARNESS, and this is the load-bearing half: it sits in the stdin and stdout path of the most authoritative guards in the repo, so a differential test drives EVERY hook with and without it over a payload matrix and demands byte-equal stdout and equal exit status. It fails OPEN in exactly one direction — if the log cannot be written the hook behaves exactly as before — because observability that breaks a guard is worse than the blindness it replaces. Two mechanical traps are pinned by tests rather than left to care: stdout must be replayed from the FILE, since `out=$(cat f)` strips trailing newlines and delivers a guard's JSON one byte short with no parser anywhere to complain; and stdin must never be slurped when it is a TTY, because an interactive `git commit` hands its hooks a terminal and `cat` would block forever, hanging the commit the instrumentation was added to observe. | 2026-08-14 | [link](records/testing/hook-reports-its-own-execution.md) |
| `testing.live-e2e-prepush-timing` | Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. | 2026-07-21 | [link](records/testing/live-e2e-prepush-timing.md) |
| `testing.mutation-claims-are-executed` | A `MUTATION` row in `docs/guard-inventory.md` is not a statement that someone once witnessed a red. It carries a DECLARED clause mutation in `scripts/tests/mutation_manifest.py`, and `scripts/tests/test_mutation_harness.py` applies that mutation to an isolated copy of the repository on every run and requires the row's OWN named test to go red. The manifest and the MUTATION rows are compared for SET EQUALITY in both directions, so a row cannot claim the grade without a mutation and a mutation cannot outlive the grade it justifies. EXIT STATUS IS NOT THE VERDICT: each entry also declares the DIAGNOSTIC its red must carry, matched against pytest's exception output alone, because pytest reports a crashing test exactly as it reports a detecting one and a red for an unrelated reason is evidence about nothing. WHERE THE GUARD IS ITSELF A TEST, `target` may differ from `guard` and the exact-once check applies to the declared TARGET. Two shapes are admissible and the choice is not free. Where the guard's assertion IS the check — a completeness comparison against a Markdown inventory — the mutation goes into the guarded ARTIFACT, per `testing.guard-ships-with-mutation-proof`'s checker-guard exception, because mutating such a checker's own POPULATION demonstrates a false POSITIVE while proving nothing about the detection the row claims. Where the guard is a test module wrapping a separately mutable DETECTOR or helper, the clause may be in that detector, since disarming it is a real clause disarm and the module's own assertion is what notices. THE MUTATION IS DECLARED, NEVER INFERRED: a harness that guessed which clause of a 90-line hook is the guard would manufacture the confident-but-empty coverage this exists to prevent, which is why `testing.guard-ships-with-mutation-proof` rejected a generic runner. Where a proof test already names its clause in source, the manifest reuses THAT string, so a retarget in either place is caught by the other. COARSENESS IS RECORDED, NOT HIDDEN: each entry is graded `CLAUSE` or `DETECTOR`, and a `DETECTOR` entry — one whose detector accumulates faults from independent arms, so disarming any single arm leaves its proof test green — must CARRY the finer mutation that survived, which is re-run every time and required to keep surviving. Guards that are not graded `MUTATION` each carry a STATED reason in that same manifest, keyed on the guard and compared for SET EQUALITY against the inventory's `GUARD` rows in both directions — so a new guard cannot arrive without someone writing what a proof would need, and a reason cannot outlive the row it is about. Keying the reason on the row's GRADE instead is tautological (a new guard inherits one and nobody looks at it) and a pinned COUNT moves only on net change; both were tried and are rejected. The sandbox is a real git repository built from `git ls-files` with working-tree content, never a filesystem walk. | 2026-08-22 | [link](records/testing/mutation-claims-are-executed.md) |
| `testing.playwright-mcp-download-and-recovery` | In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session. | 2026-07-21 | [link](records/testing/playwright-mcp-download-and-recovery.md) |
| `testing.scripted-playout-golden-deferred` | The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it. | 2026-07-22 | [link](records/testing/scripted-playout-golden-deferred.md) |
| `testing.troubleshoot-path-cannot-test-branding` | Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding. | 2026-07-21 | [link](records/testing/troubleshoot-path-cannot-test-branding.md) |
@@ -5,8 +5,8 @@ status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: 'Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. THE COMBINED COMMIT-STATUS ENDPOINT CANNOT EXPRESS THIS: `GET /repos/{o}/{r}/commits/{sha}/status` has states `success`/`failure`/`pending`/`error` and NO `cancelled`, so it reports a cancelled job as `failure`. Anything polling that endpoint — which is what a CI monitor naturally polls, because it is the per-sha view the merge gate reads — must resolve the job-level `conclusion` via `actions/runs/{id}/jobs` before reporting a red.'
signals: 'conclusion cancelled · run-level vs job-level conclusion · pre-cancel genuine failure · CI monitor state != pending · phantom failure · commit-status endpoint has no cancelled state · combined status reports cancelled as failure · resolve job conclusion before reporting a red · paths: n/a · issues: #542, #790'
rule: Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor.
signals: 'conclusion cancelled · run-level vs job-level conclusion · pre-cancel genuine failure · CI monitor state != pending · phantom failure · paths: n/a · issues: #542'
mechanics: Gitea Actions run/job API; monitor logic, e.g. `fail=[j for j in jobs if j['conclusion']=='failure']; canc=[j for j in jobs if j['conclusion']=='cancelled']`.
---
@@ -19,15 +19,3 @@ so never claim green on one.
A monitor that only asks "is state != pending" will report a cancelled run as a failure and send the
next session debugging a phantom. Split the two counts explicitly.
**The endpoint most monitors poll cannot express the distinction at all.** `commits/{sha}/status` is
the per-sha view — the one the merge gate reads and the natural thing to watch a PR head with — and
its vocabulary is `success`/`failure`/`pending`/`error`. A cancelled job arrives there as `failure`.
So "split the counts" is not implementable against that endpoint: the job-level `conclusion` has to
be fetched from `actions/runs/{id}/jobs`, with the run id taken from the status entry's `target_url`.
Measured on ersatztv#790 (2026-08-22): three jobs reported `failure` on a head where everything that
ran had passed. They had been auto-cancelled by the author's own next push. The cause is usually
self-inflicted, which is the other half of the cost `ci.batch-pushes-no-cancel-route` and
`process.local-gate-before-push` describe — an early push does not merely waste a runner slot, it
manufactures reds that look like they belong to the diff.
@@ -1,97 +0,0 @@
---
key: ci.python-lint-ruff-config-committed
title: 2026-08-21 — Python lint is a committed ruff.toml enforced in CI, not the operator machine's global config (#780)
status: active
since: '2026-08-21'
supersedes: none
superseded-by: none
rule: The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR.
signals: 'ruff · pyright · python lint · `ruff format --check` · lint passes on my machine but not yours · no repo lint config · S105 on a test stub credential · paths: `ruff.toml`, `.gitea/workflows/pr-checks.yml` · issues: #780, #773, #648, #512'
mechanics: 'Config at repo root; `.gitea/workflows/pr-checks.yml` -> `script-tests` pins `ruff==0.12.11` via pip and runs both commands ahead of the jq preflight and pytest. Population is `git ls-files -z ''*.py'' ''*.pyi'' ''*.ipynb''` passed explicitly with `--no-force-exclude`, guarded by an empty-list arm; discovery-based invocation is defeated by an `exclude` in three config scopes, two of them per command. Bumping the pin is a deliberate PR because a new ruff release adds rules.'
---
The global instructions tell every session to run `ruff check`, `ruff format --check` and `pyright`
after touching Python. Before this, the repo enforced none of them and committed no config, so ruff
fell back to whichever `~/.config/ruff/ruff.toml` the operator's machine happened to have — **a second
machine lints this repo differently, or not at all.** That is the same shape as #643/#647/#648 (a
shell gate whose behaviour was a function of an untested interpreter version) and #512 (a test that
passed on a fast laptop and flaked on a starved CI VM): the verdict was a property of the environment
rather than of the repo.
The committed config is the operator's global one apart from `per-file-ignores`, which is narrowed
to `scripts/tests/**`. That is what the tree was de-facto written against, so adopting it cost a
mechanical reformat rather than a rewrite: 74 findings against `706674272`, of which 57 were fixed in
code (mostly by the format pass) and 17 carry a per-site `# noqa` with its reason inline. `RUF100` is
selected so those suppressions stay honest — a `# noqa` that suppresses nothing is otherwise
invisible, and three were live the moment the rule was switched on: one whose rule had stopped firing,
one for a rule this config never enables, and one added mid-branch on a site the same branch had
already fixed in code.
**One exemption is directory-wide, and it is the boring one.** `S101` for `scripts/tests/**`, because
a test suite asserts. **`S105` is deliberately NOT directory-wide.** All eight of its hits among
those 74 findings are stub credentials handed to the real hooks (`env["ETV_GITEA_TOKEN"] = "stub"`),
with no true positive in the tree today (a ninth `# noqa: S105` predates this and sits on a
commit-message marker in `decisions_validate.py`). A directory blanket would give up
hardcoded-credential coverage over the largest Python surface in the repo, permanently, to suppress
eight known lines — and this is the only Python lint the repo runs, so nothing else would catch a real
token pasted into a fixture next year. Per-site `# noqa: S105` costs the same and keeps the rule live.
**The population comes from `git ls-files`, not from ruff's discovery, and that is the load-bearing
part.** `ruff check .` reports on what it *discovers*, and an `exclude` defeats discovery in three
different config scopes — including `[format]`, which is where an appended line lands by TOML rules (two of the
three defeat each command). Measured with ruff 0.12.11 and `exclude = ["scripts/**"]`, against a
tracked file holding an unused import, a hardcoded credential and a formatting error. The pattern
matters: `exclude` is matched per FILE, so a bare `["scripts"]` works at the top level but matches
nothing under `[lint]`/`[format]`. GREEN means the gate was silently off:
| `exclude` in | `ruff check .` | explicit `check` | `ruff format --check .` | explicit `format` |
|---|---|---|---|---|
| top level | GREEN | red | GREEN | red |
| `[lint]` | GREEN | red | red | red |
| `[format]` | red | red | GREEN | red |
| top + `force-exclude` | GREEN | GREEN without `--no-force-exclude`, red with it | GREEN | same |
Only the top-level scope empties both discovery commands; `[lint]` empties `check`, `[format]` empties
`format --check`, so in those two the job would still redden on the other step. `[format]` is where a
line appended to `ruff.toml` lands, by TOML rules. The last row is the whole reason for the flag.
`include = []`, `extend-exclude` and a nested `scripts/ruff.toml` were tried too, and are equally
inert against the explicit form. The empty-list arm is the anti-vacuity
check — `ruff check .` over no files exits **0** with a stderr warning, so an emptied population is a
green gate, not a red one. Enumerating from git also covers tracked-but-gitignored files, which
discovery skips (`git add -f` under an ignored path is established practice here).
**The rule set is not covered, and that is a stated limit rather than an oversight.** `select = []`
silences every selected rule, so the `ruff check` step goes green over any lint violation (a syntax
error still reds) while still printing a reassuring file count. `ruff format --check` is unaffected, because formatting is not rule-selected. So the population
arm makes an emptied *file* set loud, nothing makes an emptied *rule* set loud, and half the gate is
killable by a config edit only a reviewer catches.
Both steps were witnessed red on the runner before merge, not argued to work — **on the body that
shipped**: run 2173 job 9176 (`❌ Failure - Main Lint scripts (ruff check)` on an `F401`) and run 2170
job 9163 (`❌ Failure - Main Lint scripts (ruff format --check)`), printing `Linting 34 tracked
Python files` and `Format-checking 34 tracked Python files` — the population arm executing (34 = the
33 tracked files plus the probe; the merged tree has 33). Each came from a temporary probe commit
reverted before merge. Two probes are needed, not one: a check-dirty file stops the job
before the format step ever runs. Earlier reds against the previous, discovery-based bodies were
discarded rather than cited — a proof belongs to the code that ran, not to its predecessor.
**Lint runs early in the job, ahead of the jq preflight.** `Preflight jq version` is a hard `--expect`
tripwire; a lint step behind it stops running for as long as the jq contract is broken, under a red
that names jq. Ordering is the difference between a gate that is skipped and one that is not. The
`git` half of `Preflight external tools` stays *ahead* of the lint steps, because they consume `git`:
without it, a missing git arrives as an empty population and both steps report a population problem
instead of the missing tool.
**`pyright` is deliberately NOT gated.** Its only findings here are `reportMissingImports` for
`etv_client` in `scripts/scripted-schedules/entrypoint.py`, resolvable only inside that script's
deploy environment. Gating it would put a node toolchain on the git-only `small` lane to find nothing.
Revisit when this repo grows a typed Python surface — the reason is the cost/finding ratio today, not
a judgement that type checking does not belong.
**The pin is the second half of the fix.** An unpinned `pip install ruff` re-introduces exactly the
divergence the config closes, one layer up: the verdict becomes a function of *when* the job ran. Same
argument as the `jq` pin in the same job (`ci.jq-version-contract`), and the same consequence — a bump
is a PR someone reads. `pytest` and `pyyaml` in the same job stay unpinned, and the
asymmetry is the point rather than an oversight: a pytest release does not add assertions to your
suite, a ruff release adds rules to your lint.
@@ -7,7 +7,7 @@ supersedes: none
superseded-by: none
rule: 'A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`''s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key>` as its FIRST act, and the job''s LAST step calls `ci-step-ran.sh assert --always <keys> --gated <keys>`, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step''s `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked.'
signals: 'required check green but no work done, step never ran but job green, Build & test green in seconds, EF migration integrity green without replaying, missing Run Main step marker, Unable to interpolate expression format(, dropped step docker-build, ci-step-ran.sh, marker file, expression delimiter in a required job · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-step-ran.sh`, `scripts/tests/test_ci_dropped_step_guard.py`, `scripts/tests/test_ci_release_path_scan_job.py` · issues: #756, #751, #684, #767'
mechanics: '`scripts/ci-step-ran.sh` owns the marker path so it exists ONCE and the write and the read cannot diverge. It is keyed on `GITHUB_JOB`/`GITHUB_RUN_ID` — REQUIRED, refusing rather than falling back to a reusable name — plus `GITHUB_RUN_ATTEMPT`. All three REFUSE rather than falling back to a reusable name. The third was warn-and-default until its presence was measured: grepping a log for the variable NAME proves nothing, and inferring it from the absence of a stderr warning proves nothing either (stderr capture was itself unestablished), so `assert` was made to print `Marker identity: job=… run=… attempt=… (from the runner)` on STDOUT and the answer was read off run 1916 for both required jobs. That line is retained as standing evidence. Do NOT justify the keying with #751''s "RUNNER_TEMP is /tmp, not a private per-job dir": that was measured on a job with no `container:` and does not transfer — these jobs get a fresh container, which is the primary protection, and the keying is defence in depth. Held by `scripts/tests/test_ci_dropped_step_guard.py`: static (marker set derived from the workflow equals the guard''s expectations, bucket matches each step''s `if:`, guard is last / has no `if:` / is not advisory / has no delimiter) and behavioural (the guard''s real command line executed against markers written by the steps'' real marker lines, dropping each key in turn). The release-path `scan` job (#767) runs the existing PyYAML-based ban test rather than a second implementation, so there is no drift surface; `scripts/tests/test_ci_release_path_scan_job.py` holds the WIRING instead — that `build` needs it AND that `build`''s own `if:` carries no `always()`/`!cancelled()`/`failure()` (which would downgrade the `needs:` edge to mere ordering), that `scan` carries no job-level `if:` (one excluding the tag push restores the hole, one skipping the job skips `build` too) and is not advisory at STEP or JOB level, and that its own run bodies are delimiter-free. Its load-bearing test is an EXECUTION PROBE, not a shape assertion: it runs the scan step''s real `run:` body with the full env the runner would give it (workflow, job AND step `env:` tiers) against a copy of the repo whose `Smoke` body carries an injected delimiter, and requires a non-zero exit, with a clean-tree negative control. Shape assertions were tried and lost repeatedly — from `echo`ing the command to `PYTEST_ADDOPTS` one env tier up — so do NOT replace the probe with cheaper checks about the command. Two tiers cannot be reached from inside pytest at all and are handled differently: a step writing to `$GITHUB_ENV` is BANNED by test, and repo-root pytest configuration (`pytest.ini` `addopts`, `pytest_collection_modifyitems`) can deselect any test including the guards, so the positive control is a SHELL step — `scripts/ci-prove-ban-detects.sh` poisons the checked-out workflow in the REAL checkout, re-runs the ban test, and vouches ONLY for the ban test''s `build` parametrisation failing — any other outcome (exit 5 from a total deselect, exit 2 from a collection error, an unrelated test failing) is a REFUSAL, not a pass, because each weaker reading was a live bug in an earlier draft and the deselection disarm it exists to catch exits 5 rather than 1. That script is itself positively controlled — `test_the_PROOF_SCRIPT_itself_refuses_when_the_ban_is_deselected` and `test_the_PROOF_SCRIPT_refuses_when_the_WRONG_test_fails` cover the two refusal branches a disarm actually lands on, each verified by making that branch alone unreachable — since it was for a while the one guard exercised only on the happy path. The third branch (pytest passing outright) has no control and does not need one: neutering it falls through to the exit-code branch, which still refuses. A copy-based proof is not equivalent: it does not inherit the repo-root config a disarm would live in. Its steps carry markers and a trailing assert of their own, verified by the same drop-each-key-in-turn behavioural pattern. CARVE-OUT: the "fresh container is the primary protection, keying is defence in depth" reasoning above does NOT cover the container-free jobs — `scan`, and `toolchain-preflight` since #772. Neither has a `container:` and both run on `small`, where RUNNER_TEMP is the shared host /tmp, so for those jobs the run-id/attempt keying is the ONLY protection. The MEMBERSHIP is the part that rots: a further container-free job added without joining this list silently inherits a reassurance nobody checked for it. Residual: a single-job re-run that does not increment GITHUB_RUN_ATTEMPT would find the prior attempt''s marker file and the assert would pass even with the pytest step dropped. Identity on that lane was measured, not assumed — run 1929 printed `Marker identity: job=scan run=1929 attempt=1 (from the runner)`.'
mechanics: '`scripts/ci-step-ran.sh` owns the marker path so it exists ONCE and the write and the read cannot diverge. It is keyed on `GITHUB_JOB`/`GITHUB_RUN_ID` — REQUIRED, refusing rather than falling back to a reusable name — plus `GITHUB_RUN_ATTEMPT`. All three REFUSE rather than falling back to a reusable name. The third was warn-and-default until its presence was measured: grepping a log for the variable NAME proves nothing, and inferring it from the absence of a stderr warning proves nothing either (stderr capture was itself unestablished), so `assert` was made to print `Marker identity: job=… run=… attempt=… (from the runner)` on STDOUT and the answer was read off run 1916 for both required jobs. That line is retained as standing evidence. Do NOT justify the keying with #751''s "RUNNER_TEMP is /tmp, not a private per-job dir": that was measured on a job with no `container:` and does not transfer — these jobs get a fresh container, which is the primary protection, and the keying is defence in depth. Held by `scripts/tests/test_ci_dropped_step_guard.py`: static (marker set derived from the workflow equals the guard''s expectations, bucket matches each step''s `if:`, guard is last / has no `if:` / is not advisory / has no delimiter) and behavioural (the guard''s real command line executed against markers written by the steps'' real marker lines, dropping each key in turn). The release-path `scan` job (#767) runs the existing PyYAML-based ban test rather than a second implementation, so there is no drift surface; `scripts/tests/test_ci_release_path_scan_job.py` holds the WIRING instead — that `build` needs it AND that `build`''s own `if:` carries no `always()`/`!cancelled()`/`failure()` (which would downgrade the `needs:` edge to mere ordering), that `scan` carries no job-level `if:` (one excluding the tag push restores the hole, one skipping the job skips `build` too) and is not advisory at STEP or JOB level, and that its own run bodies are delimiter-free. Its load-bearing test is an EXECUTION PROBE, not a shape assertion: it runs the scan step''s real `run:` body with the full env the runner would give it (workflow, job AND step `env:` tiers) against a copy of the repo whose `Smoke` body carries an injected delimiter, and requires a non-zero exit, with a clean-tree negative control. Shape assertions were tried and lost repeatedly — from `echo`ing the command to `PYTEST_ADDOPTS` one env tier up — so do NOT replace the probe with cheaper checks about the command. Two tiers cannot be reached from inside pytest at all and are handled differently: a step writing to `$GITHUB_ENV` is BANNED by test, and repo-root pytest configuration (`pytest.ini` `addopts`, `pytest_collection_modifyitems`) can deselect any test including the guards, so the positive control is a SHELL step — `scripts/ci-prove-ban-detects.sh` poisons the checked-out workflow in the REAL checkout, re-runs the ban test, and vouches ONLY for the ban test''s `build` parametrisation failing — any other outcome (exit 5 from a total deselect, exit 2 from a collection error, an unrelated test failing) is a REFUSAL, not a pass, because each weaker reading was a live bug in an earlier draft and the deselection disarm it exists to catch exits 5 rather than 1. That script is itself positively controlled — `test_the_PROOF_SCRIPT_itself_refuses_when_the_ban_is_deselected` and `test_the_PROOF_SCRIPT_refuses_when_the_WRONG_test_fails` cover the two refusal branches a disarm actually lands on, each verified by making that branch alone unreachable — since it was for a while the one guard exercised only on the happy path. The third branch (pytest passing outright) has no control and does not need one: neutering it falls through to the exit-code branch, which still refuses. A copy-based proof is not equivalent: it does not inherit the repo-root config a disarm would live in. Its steps carry markers and a trailing assert of their own, verified by the same drop-each-key-in-turn behavioural pattern. CARVE-OUT: the "fresh container is the primary protection, keying is defence in depth" reasoning above does NOT cover `scan` — it has no `container:` and runs on `small`, where RUNNER_TEMP is the shared host /tmp, so for that job the run-id/attempt keying is the ONLY protection. Residual: a single-job re-run that does not increment GITHUB_RUN_ATTEMPT would find the prior attempt''s marker file and the assert would pass even with the pytest step dropped. Identity on that lane was measured, not assumed — run 1929 printed `Marker identity: job=scan run=1929 attempt=1 (from the runner)`.'
---
**Why per step, when #756 proposed per job.** A job-start marker answers "did this job begin", which
@@ -5,7 +5,7 @@ status: active
since: '2026-07-26'
supersedes: none
superseded-by: none
rule: 'The `scripts/tests/` pytest suite runs on every PR as a dedicated `script-tests` job in `pr-checks.yml` (`runs-on: small`, `setup-python` + `pip install pytest pyyaml`, `PYTHONPATH=. python3 -m pytest scripts/tests -q`; since #780 it also runs a pinned ruff over a `git ls-files` population first), unconditionally rather than behind a `scripts/**` path filter, and **never as a step inside `decisions-guard`** — a job whose reds a standing rule instructs sessions to ignore must never host a gate whose reds are real. Any new CI gate must be reachable by a failure that is unambiguously attributable to it.'
rule: 'The `scripts/tests/` pytest suite runs on every PR as a dedicated `script-tests` job in `pr-checks.yml` (`runs-on: small`, `setup-python` + `pip install pytest`, `PYTHONPATH=. python3 -m pytest scripts/tests -q`), unconditionally rather than behind a `scripts/**` path filter, and **never as a step inside `decisions-guard`** — a job whose reds a standing rule instructs sessions to ignore must never host a gate whose reds are real. Any new CI gate must be reachable by a failure that is unambiguously attributable to it.'
signals: 'scripts/tests never ran in CI, pytest not in any workflow, python test suite local-only, decorative test, decisions-guard runs the code not the tests, script-tests job, small lane pytest, negative control CI goes red · paths: `.gitea/workflows/pr-checks.yml`, `scripts/tests/`, `docs/ci-cd.md` · issues: #631, #610, #621, #622, #542'
mechanics: '`.gitea/workflows/pr-checks.yml` -> `script-tests`; `docs/ci-cd.md` -> "`script-tests` job"'
---
@@ -29,7 +29,7 @@ it lives in, so **a job under a standing ignore-rule can host no real gate.**
This does not conflict with `ci.ui-e2e-harness` ("never their own job"). That record folds UI-E2E
into `functional-e2e` because the specs need an app the job has *already booted* — sharing expensive
setup. Here there is no shared setup to reuse (a checkout plus `pip install pytest pyyaml` and a pinned ruff), and the
setup. Here there is no shared setup to reuse (a checkout plus `pip install pytest pyyaml`), and the
sibling job carries an ignore-rule. Same question, opposite answers, for stated reasons.
**Unconditional, not path-filtered.** The suite's real input set spans more than `scripts/`:
@@ -104,9 +104,3 @@ from a proxy ("fewer than we asked for", "jq didn't complain").
It is not yet a *required* status check — `main` requires only `Build & test (.NET)`,
`EF migration integrity` and `review-verdict/h10`. It reddens the run; promoting it to required is a
branch-protection change left deliberately separate.
Since #780 the job also lints Python before pytest, so its display name is
`Script lint and tests (ruff + pytest)`. It does **not** invoke `ruff check .` — the invocation and
the reasons for its exact shape are `ci.python-lint-ruff-config-committed`. Why the lint lives here
rather than in a job of its own: it needs the same `setup-python`, it costs seconds, and a second job would double the dispatch overhead
this file exists to keep small.
@@ -1,56 +0,0 @@
---
key: docs.no-session-narrative
title: '2026-08-21 — a doc records the end state; the path to it goes in the commit message, not the artifact (#784)'
status: active
since: '2026-08-21'
supersedes: none
superseded-by: none
rule: 'Every durable artifact — an in-repo `docs/` page, a skill, a README, a code comment, an Obsidian vault page — records the END STATE. The path to that end state goes in the commit message, the Gitea issue, or the issue''s `## Closing record`; it does not go in the artifact. Concretely: **a review finding is answered in the commit message, and only the corrected claim enters the doc.** Naming the destination is load-bearing — "do not write it in the doc" with no home loses the knowledge, and this repo has the inverse failure on record too (#542, where a pruned narrative turned out to be the only copy). THE TEST IS WHO BENEFITS: if only the author''s timeline explains why a sentence is there, it is narrative and belongs in the commit; if a reader who never saw the session would act differently knowing it, it is a finding and stays. Session narrative reads as: first person or session chronology ("I initially thought", "an earlier draft counted", "my first attempt returned 0"), a correction of a belief the reader never held ("this was wrong, actually X" where only X matters), relative time ("earlier today", "currently investigating"), or a blow-by-blow diagnosis standing in place of the conclusion. THE CARVE-OUT, which must be stated or the rule gets over-applied — reader-facing history that must survive: a decision record''s `supersedes`/`superseded-by`; a dated measurement or an explicitly stated snapshot boundary; a TESTED-AND-REJECTED negative result, kept so nobody re-proposes it on plausibility; the *why* behind a non-obvious choice; and a trap together with its consequence. `docs/decisions/records/**` and `docs/decisions/archive/**` are exempt WHOLESALE: a record narrating how a rule was got wrong is carrying the rationale it exists to carry. ENFORCEMENT IS ADVISORY ONLY — `scripts/check-doc-narrative.py`, run non-blocking from the `docs-reminder` job over ADDED lines. It is a string predicate over prose and may never become a blocking gate.'
signals: 'no session narrative in docs · the reader never saw the earlier draft · who-benefits test · answer a review finding in the commit message not the doc · end state versus path to it · earlier draft · first version of this table · my first attempt · relative time in a doc · carve-out for dated measurement and snapshot boundary · tested-and-rejected negative result · decision records are exempt on purpose · advisory not blocking · string predicate over prose · paths: `scripts/check-doc-narrative.py`, `.gitea/workflows/pr-checks.yml`, `docs/handoffs/chicorytv-issue-queue.md`, `docs/defect-shapes-773.md` · issues: #784, #773, #767, #743, #542'
mechanics: 'The RULE is about every durable artifact; the DETECTOR''s population is narrower and is stated here so a row in it never reads as coverage it does not have. `scripts/check-doc-narrative.py` scans `docs/**/*.md` minus `docs/decisions/**` (exempt wholesale, in both modes), plus root-level `*.md`. Skills under `.claude/`, `web/`, and every other nested markdown file outside `docs/` are OUT of its scope and stay a human judgement. It warns and exits 0 on every path — bad argument, unresolvable ref, unreadable file, unhandled exception — asserted per argument shape in `scripts/tests/test_check_doc_narrative.py`, not only in prose, EXCEPT the unhandled-exception arm, which is a bare `except` no test exercises and is recorded as unproven rather than implied. The invariant is held at the JOB level too (`continue-on-error` on both steps): a script that returns 0 does not keep a job green if a setup action fails. Every knob DEMONSTRATED to break the parse has a row in `FORMAT_KNOBS` in the test file; the user and system config files are removed from the picture entirely so an unnamed one cannot reach it. Three separate review rounds each found ONE more knob turning a real hit into a clean-looking `scanned 0 file(s)`, so the third fix removed the surface rather than naming a fourth knob — a completeness claim over the knob space would be exactly the enumeration that failed three times. COVERAGE BOUNDARY, written once so it is not rediscovered one clause at a time, and naming the clauses rather than a category because a category is where the last mis-sort hid. MUTATION-PROVEN: the parse path (hunk state, the `\` marker, the `diff --git` reset, `splitlines`, the population count, `re.IGNORECASE`, the SCANNED-NOTHING return) and five pins — `core.quotePath=false`, `--find-renames`, `--dst-prefix`, `--no-ext-diff`, `--no-color`. UNPROVEN and defensive, recorded as such rather than implied to be covered: `-U0` (the context arm handles any `-U`, so removing it reddens nothing), the `GIT_CONFIG_GLOBAL`/`SYSTEM`/`NOSYSTEM` overrides (which exist for the UNNAMED knob and therefore cannot be witnessed — the script says so itself), `--src-prefix`, the `+++ /dev/null` deletion arm, the malformed-`@@` arm, the stderr relay, `git()`''s `OSError` return, `run_all`''s failure sentinel, `open(errors=)` and the top-level `except`. Both lists ENUMERATE; neither is a universal over the file, and a clause on neither list has simply not been measured. `--diff` scans only lines ADDED in the PR (with rename detection, so a `git mv` does not re-flag a file''s pre-existing content) and reports `SCANNED NOTHING` rather than a clean-looking line when it cannot resolve the base. `--all` sweeps the tracked corpus from `git ls-files`, never a filesystem walk (#778). Two consequences of added-lines-only are deliberate and stated so they are not mistaken for coverage: a file MOVED into the population (`web/x.md` to `docs/x.md`) is never scanned by any PR, and neither is anything already in the corpus. `--all` is the only thing that sees either, which is why the sweep is a task a person runs rather than a job.'
---
The rule already existed, correctly stated, and scoped to exactly one file. The kickoff handoff said
a paragraph of narrative there is a tax paid by every future session, *because that file is pasted
into every session*. That reason is file-specific. The general reason is broader and applies to
every doc in the repo: **a reader coming to a doc cold never saw the earlier draft**, so "we
previously got this wrong" carries nothing they can act on.
This is the recurring shape `#773` measures — a rule established where it was first noticed and
never extended to its class. It is the same shape as #743, where a control defended the merge path
while the push path stayed open, and as #767's record puts it: *"'the ban is enforced' and 'the ban
is enforced **where it matters**' were never separated."* The generalisation is the fix; the six
stripped instances in `docs/defect-shapes-773.md` were the symptom.
**Why it leaks — a mechanism, not a discipline problem.** A review finding creates pressure to
answer *in the artifact*. The correction and the justification-for-the-correction get written in the
same keystroke, and the artifact is the file already open, so the justification lands there too.
Every one of the six instances came out of a review round. That is why the rule names a destination
rather than only a prohibition: the commit message is written at the same moment, is permanent, and
is where provenance is actually looked for.
**Why the carve-out is half the rule.** Over-stripping is the more common failure. A doc cut to bare
facts reads as arbitrary and the next reader "fixes" it back — which is how a rejected approach gets
re-proposed and a trap gets re-sprung. `docs/defect-shapes-773.md` carries both a dated measurement
and a struck-through `shellcheck` row recording a *tested* negative result; both are reader-facing
history and both must survive. The who-benefits test is what separates them from narrative, and it
is stated as a question rather than a word list because a word list is exactly what the mechanised
half of this cannot be trusted to be.
**Why enforcement is advisory and stays that way.** A narrative detector is a string predicate over
prose. `docs/defect-shapes-773.md` §4 and `testing.guard-derives-population-from-source` both argue
that a weak string-matching detector is the symptom-keyed mistake, and the withdrawn
`test_review_verdict_vocabulary_parity.py` — six cold-review rounds, then deleted — is the empirical
case: every round's fix was locally correct and the sequence never converged. As a blocking gate
this is a bad bet. As a non-blocking nudge it is nearly free, and the repo already runs that exact
pattern in `docs-reminder` for the parity doc. So the detector warns; it never fails a run. Budget
for it being wrong sometimes, and make that acceptable by not letting it block.
**It is Python, not shell, and that is the same argument one level down.** A detector over diff output has to decide what each line IS, and deciding that from its prefix alone — without hunk state — is a string predicate too. Four distinct defects fell out of one bash implementation of it: the no-trailing-newline marker counted as content, an added line whose own text began `++ ` eaten by the `+++ ` header arm, `core.quotePath` hiding non-ASCII paths, and a final unterminated line dropped by `read`. Those are four sites of one mistake, so the mechanism was replaced rather than the sites patched one at a time. Anything that parses a diff here should parse it with hunk state or not at all.
**If this ever becomes a hook, it belongs at USER scope, not in this repo.** The rule is not
ersatztv-specific — it is true of every repo — and an ersatztv-only hook would enforce it exactly
where it was first noticed and nowhere else, which is the shape this record exists to close.
Mechanically that means `~/.claude/hooks/` plus the user `settings.json`. Worth stating because
`~/.claude/hooks/` holds only cosmetic hooks today (terminal title, statusline), so there is no
precedent there for a content rule and someone would have to decide that deliberately.
@@ -1,68 +0,0 @@
---
key: media.nullable-primitive-collection-mutation
title: '2026-08-22 — A nullable primitive collection is guarded at the READ SITE and never assigned back onto a possibly-tracked entity (#701)'
status: active
since: '2026-08-22'
supersedes: none
superseded-by: none
rule: 'Code that reads a nullable EF PRIMITIVE COLLECTION guards it at the read site — `Optional(x).Flatten()` hoisted into a local — and NEVER writes the guard back onto the entity with `??= []`. Such a collection is stored WHOLE, in ONE COLUMN, so unlike the navigation collections that the same `??= []` idiom guards harmlessly all over this repo, the property IS the column value: assigning it on a TRACKED entity flips the entry to `Modified` and the next `SaveChanges` persists `[]` over what the database held as `NULL`. The population is derived from the MODEL, not from a grep of the domain classes, and is currently EIGHT columns: `SongMetadata.Artists`/`AlbumArtists` (EF-native primitive collections, no explicit configuration, stored as a JSON array) plus the six value-converted collections registered in `ErsatzTV.Infrastructure/Data/Configurations``ProgramScheduleAlternate` and `PlayoutTemplate` each carrying `DaysOfMonth`, `MonthsOfYear` (`IntCollectionValueConverter`, COMMA-SEPARATED text, not JSON) and `DaysOfWeek` (`EnumCollectionJsonValueConverter`, JSON). The shared property that matters is not the serialization format but that the collection is ONE SCALAR COLUMN, so do not reason from EF-native primitive-collection behaviour when standing in front of a converter. Only the `SongMetadata` pair is left NULL in practice, because `FallbackMetadataProvider` never assigns it. No site applies `??=` to any of the six (`grep -rn ''DaysOfMonth ??=\|MonthsOfYear ??=\|DaysOfWeek ??='' --include=''*.cs'' .` returns 0 at time of writing), so THIS defect has no instance there; whether a null can reach one of them at runtime is a SEPARATE question this record does not answer and does not assert — the API request records normalize with `?? []`, but `ReplacePlayoutAlternateScheduleItemsHandler` and `ReplacePlayoutTemplateItemsHandler` assign the command value straight onto the entity, so a non-API caller is UNVERIFIED (#823). A grep for `IList<string>` finds only two of the eight and an enum collection none of them — so a sweep follows the FIELD via the model configuration, not the file: the mutation and every read it was protecting must move together, or removing the assignment trades a silent write for a live throw. The read FORM decides which throw, and BOTH occur in this one PR: `foreach` over a null collection throws `NullReferenceException` (the two Lucene reads — measured), while `string.Join`/`Enumerable.ToList` on a null SOURCE throw `ArgumentNullException` (the two Elastic reads, and the `#671` mapper sites). Neither exception type alone characterises the class, so neither grep finds it — which is the actual reason a sweep must follow the FIELD rather than an exception name. Whether today''s callers happen to be `AsNoTracking` is NOT the safety argument and must not be written down as one: it is a property of the current callers, not of the code holding the entity, and it is what #691 recorded as a loaded gun.'
signals: 'Artists ??= [] on a tracked entity · nullable primitive collection not a navigation · JSON array in one column · value converter is the same hazard as a primitive collection · IntCollectionValueConverter EnumCollectionJsonValueConverter · derive the collection-column population from the model configuration · SaveChanges writes empty array over NULL · entity flips to Modified on a read guard · AsNoTracking today is not a safety argument · untagged song loses its NULL artists · foreach over null throws NRE while string.Join throws ArgumentNullException · sweep by FIELD not by file · Optional Flatten hoisted local · paths: `ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs`, `ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs`, `ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs`, `ErsatzTV.Tests/Integration/SongIndexerMetadataMutationTests.cs` · issues: #701, #691, #671, #823, #824'
mechanics: 'Pinned by `SongIndexerMetadataMutationTests.UpdateSong_Must_Not_Mutate_Nullable_Artists_On_A_Tracked_Entity`, which drives the real `LuceneSearchIndex` against a real `TvContext` on SQLite with a deliberately TRACKED song. That fixture covers LUCENE ONLY — `ElasticSearchIndex` holds an independent copy of the same code and needs a stubbed transport, so an Elastic-only reintroduction stays green (#824). No repo-wide detector: the `??=` idiom is correct on navigations and appears 92 times across the app projects (`grep -rn ''??= '' --include=''*.cs'' ErsatzTV ErsatzTV.Core ErsatzTV.Application ErsatzTV.Infrastructure ErsatzTV.Scanner | grep -v ''/obj/\|/bin/'' | grep -c ''''`, 2026-08-22), so a grep for it would be noise — the eight-column population is small enough to sweep by field instead.'
---
**Deriving this population from the domain classes gives the wrong answer.** "The `IList<string>`
properties under `ErsatzTV.Core/Domain`" is the derivation that looks obviously right, and it is
REJECTED: it returns two of the eight. It is blind to the six value-converted collections, which are
declared as ordinary `ICollection<int>` / `ICollection<DayOfWeek>` and become single columns only in
`ErsatzTV.Infrastructure/Data/Configurations`, and blind to enum collections entirely. The
authoritative source is the model configuration — `HasConversion<*CollectionValueConverter, …>` plus
EF's native primitive-collection mapping. This is `testing.guard-derives-population-from-source`
applied to a sweep rather than to a guard.
**The idiom is right almost everywhere it appears, which is what makes this hard to see.** `??= []`
on `metadata.Genres`, `metadata.Tags`, `metadata.Artwork` and their kin is harmless: those are
navigation collections, and setting a null navigation to an empty list is not a scalar property
change, so EF has nothing to persist. The reader who wrote `metadata.Artists ??= []` two lines below
`metadata.Genres ??= []` was following the surrounding code correctly. The difference is invisible at
the call site and lives in the model: `Artists` is a primitive collection — one column holding the
whole list.
**Why the "it is `AsNoTracking` today" argument is banned rather than merely weak.** Both feeds into
the search indexer — `SearchRepository.GetItemToIndex` and `SearchRepository.GetAllSongs` — are
`AsNoTracking`, so no shipped caller loses data, and that was true when #691 looked at it too. It is
a fact about two callers. Nothing in the indexer requires it, nothing tests for it, and a future
caller that drops `AsNoTracking` to reuse an existing context reintroduces silent data loss with no
diff anywhere near the indexer. Writing the observation down as a justification is what converts a
latent bug into a checked decision that talks the next reader out of verifying.
**The measurement, so it is not re-argued.** Restoring only the `??= []` clause — the real predecessor
lines, not a hand-written mutant — and re-running the fixture reports
`metadata.Artists should be null but was []`, and stops there: the first assertion short-circuits.
The persistence half needs a probe VARIANT with assertions 1 and 2 replaced by prints, which reports
`STATE=Modified` and the raw column moving from `NULL` to `"[]"`. Both halves were executed. The
recipe is spelled out to that level of detail because the short version is not runnable as stated:
following it produces only the first failure, which reads as the record overstating itself.
**The fixture's two anti-vacuity guards.** A POSITIVE CONTROL (`writer.NumDocs.ShouldBe(1)`) is
required because every other assertion says something did NOT happen, so all of them hold vacuously
if `UpdateSong` never runs. Measured: gate `UpdateItems` on `_initialized` — which this fixture
bypasses by injecting the writer, so it is a plausible refactor — and with the control removed the
test PASSES with the code under test unreachable. Separately, the fixture fails loudly if
`UpdateSong` throws, because that method wraps its whole body in a `catch` that assigns
`metadata.Song = null` — which severs a required relationship and cascades the metadata to
`Deleted`. Without that check a probe silently measures the error path and reports the wrong cause,
and the raw-column helper likewise fails on a MISSING row, since `ExecuteScalar` returns CLR null
both for a NULL column and for no such row.
**Removing the assignment is not sufficient on its own.** The `??= []` was load-bearing for the four
reads below it (`foreach (string artist in metadata.Artists)`, `metadata.Artists.ToList()`). Deleting
it alone converts a silent write into a live throw on every untagged song — measured, by deleting
only those two lines from the real predecessor file: `NullReferenceException`, thrown at the
`foreach`. (Cited by SYMBOL deliberately: a line number in a mutant that exists in no committed tree
is unreproducible by construction.) The exception type follows the read FORM, not the field:
`foreach` yields NRE, `string.Join`/`ToList` yield `ArgumentNullException`, and this PR contains two
of each. That is the same trap #691 hit from the other direction, and it is why the rule pairs the
removal with the read-site guard rather than stating them separately.
Related: `api.selection-projection-include-chain` (#671) records the read-site guard itself and the
sweep-by-FIELD instruction; this record covers the write half it does not address.
@@ -1,138 +0,0 @@
---
key: process.check-and-use-pins-a-version
title: '2026-08-16 — a check and the action it authorizes are bound to one version, or the gap is stated and fenced (#778)'
status: active
since: '2026-08-16'
supersedes: none
superseded-by: none
rule: 'Where a CHECK authorizes an ACTION over state that can change in between, the two are bound to ONE version of that state. Binding alone is not enough and is the half that keeps being skipped: a snapshot nothing re-validates is not pinned, it is a stale read wearing a version number. Three substrates, three mechanisms, and they are the SAME rule — in-process, a compare-exchange claim taken by the caller, never a `Volatile.Read` in one place and an `Interlocked` in another (`ffmpeg.work-ahead-slot-atomic`); over our own HTTP API, RFC 7232 `If-Match`/ETag, with the force-write path named explicitly rather than left implicit (`concurrency.ifmatch-rfc7232`, `concurrency.force-write-non-ifmatch`); against a remote service, a full commit sha, an image digest or a monotonic event count re-read immediately before the write. Prefer true compare-and-set where the server offers it. Where it does not — Gitea''s commit-status API has no ETag, no If-Match and no expected-previous-state — the ceiling is READ-COMPARE-REFUSE: re-read the identifier immediately before the write and FAIL CLOSED on any movement, which narrows the window to one round trip and makes the loss observable instead of silent. A residual that cannot be closed is STATED in the code and carried in `docs/remote-state-inventory.md` as `UNSAFE-KNOWN` with the reason it is tolerable; "noticed" is not "accepted". Two identifier traps are load-bearing here: compare the FULL sha, never a 7-char prefix, and compare a base BRANCH REF rather than its tip sha, because the tip moves on every unrelated merge and comparing it deadlocks every open PR. Finally, and this is the failure #778 actually found: a mitigation that lives OUTSIDE the code relying on it — branch protection, a required status context, a server-side refusal — must be VERIFIED at the point of use, not asserted in a comment or in the reason string a human reads. A dated claim about configuration is not a check, and it is worse than no claim, because it talks the next reader out of looking.'
signals: 'check-and-use race · TOCTOU over remote state · pin a version or compare-and-set · read-compare-refuse · fail closed on movement · snapshot that stops being true · full sha never a 7-char prefix · base ref not base tip sha · monotonic event count not a branch name · ABA · required status check verified not asserted · the mitigation lives outside the code that relies on it · `UNSAFE-KNOWN` with a stated reason · paths: `docs/remote-state-inventory.md`, `scripts/tests/test_remote_state_inventory.py`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/post-review-verdict.sh`, `scripts/pr-changed-files.sh` · issues: #778, #773, #707, #706, #632, #622, #536'
mechanics: 'The detector is not a linter — there is no way to spot "this code should have pinned a sha". It is `testing.guard-derives-population-from-source` applied to an enumerated inventory: `docs/remote-state-inventory.md` classifies every in-scope executable `PINNED`/`CAS`/`UNSAFE-KNOWN`/`N/A`, and `scripts/tests/test_remote_state_inventory.py` derives the population from `git ls-files` — the index, never the filesystem, which reports untracked build output and differs per machine — and asserts set equality both ways, so a new script that talks to a remote service cannot ship unclassified. Nothing checks that a `PINNED` claim is true; that stays with review.'
---
`docs/defect-shapes-773.md` §3 names this as **Family D**, the one class #773's taxonomy had no
bucket for at all. Three raters proposed it unprompted; a fourth, working blind, proposed it again
under its own name. Five records: #536, #622, #632, #706, #707.
**The unifying property, and why the name matters.** A check and the action it authorizes are
separated in time over state that can change in between, with nothing pinning a version. #622's
own record states it exactly: *"The gate was never bypassed — it was satisfied against a snapshot
that stops being true."* That sentence is the whole class. Nothing is mis-scoped and no predicate
is wrong; the answer was simply computed about a different world than the one the action lands in.
**The repo had already solved this twice without noticing it was one problem.** #536 was fixed with
a compare-exchange claim. The whole `/api/v1` write surface was given RFC 7232 `If-Match` a year
earlier. Both are this rule; neither pointed at the other, and the tooling — the third substrate,
where the state is somebody else's server — got the fix a third time from scratch at #706 and #707.
That is the same one-record-per-instance growth `defect-shapes-773.md` §4 criticises in this
repo's own knowledge base, and it is the reason this record is written at the class level.
**Binding is the easy half; re-validation is the half that gets skipped.** A sha captured into a
variable and then used in a URL feels pinned and is not. `scripts/post-review-verdict.sh` is the
worked example of doing it properly: it re-reads the PR immediately before the status POST,
compares both `.head.sha` and `.base.ref`, and `die`s without writing anything on either mismatch.
The comparison, not the capture, is what makes it safe.
**Where no compare-and-set exists, say so instead of implying one.** Gitea's status API offers no
conditional write, so `review-verdict.yml` cannot make its read and its POST one operation. It
narrows the window twice — a monotonic `change_target_branch` event-count fence, and a
high-water-mark re-read that repairs a `success` posted over a human verdict back to `pending`
and then states the remaining gap in the file. The count is used rather than the branch NAME
because a name is ABA-vulnerable: `main -> S -> main` reads `main` at both ends, which is how #698
route 1 obtained a forged exemption. An honest residual is a design output. A file claiming
atomicity it does not have is the thing that stops getting re-examined.
**The failure this issue actually found, which none of the five records predicted.** The
merge-consent hook's scheduled-auto-merge path is safe only because `review-verdict/h10` is a
REQUIRED status check on `main` — a commit status belongs to one sha, so a commit pushed after
scheduling cannot inherit the verdict and Gitea refuses the merge. That is #622's fix and it
works. But it is *branch-protection configuration*. It lives outside this repo, nothing in the
repo compared the two, and the hook asserted it in a comment **and in the grant reason a human
reads**:
*"because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it
and block the merge."* Switch that context off and every word of that sentence becomes false while
the hook keeps printing it and keeps auto-granting.
So the class has a second face: not only "the state moved between the check and the action", but
"the thing that made the action safe was never observed at all". The hook now reads the repo's FULL
rule list, `GET /repos/{owner}/{repo}/branch_protections`, and classifies it — present proceeds,
unreadable **asks** (a transient failure, or a credential without the repo-admin scope that endpoint
needs, is not evidence of safety), nothing-can-govern **denies**, and anything it cannot decide
**asks**. Denying on absence is the point: that is #622's hole reopened, and its defining property
is that it is silent from the merge caller's side.
**Reading the LIST rather than the rule named after the base is the load-bearing choice**, and the
first version got it wrong in the way this record is about. `GET …/branch_protections/{name}` is an
exact database lookup that performs no matching and knows nothing about precedence, so a 200 from it
establishes only that a rule with that NAME lists the context — never that the context is required
ON that branch. Gitea resolves the governing rule by Priority first and plain-name-ness second, so a
glob rule can outrank an exactly-named one. Using the by-name endpoint first and the list only on a
404 therefore guarded the 404 path while the 200 path — the one that actually fires, since the rule
IS named `main` — granted without the check at all: hardened code that was dead, beside live code
that was not. It was fixed by DELETING the by-name path, not by documenting the gap, so there is one
fetch, one classifier and one argument to keep true. Absence is now established by the classifier
over a list that WAS read, never by an HTTP status, because a 404 from the list endpoint means the
repo was not found rather than that the branch is unprotected.
**And that check is a preflight, not a pin — say so, because the temptation is to bank it.** The
first draft of the inventory graded that path `PINNED`; cold review pointed out that the hook's
own comment concedes the read pins nothing, and it was right. Branch protection has no version,
ETag or conditional read, so an admin can still weaken it after the hook looks. What the check
buys is drift DETECTION and the removal of an unobserved assumption, which is the honest ceiling
for that API. The residual is BOUNDED, not closed, and the bound is a trust assumption worth
naming: everything on that path assumes repo-admin branch-protection config is not hostile. Saying
it was "closed one layer down" by the very protection an admin may have removed was circular — the
same sentence appeared in the inventory and was rewritten there first, which is how a stale twin
survives a fix round. A row claiming otherwise would be exactly the overclaim this record warns
about, and it is recorded here because the record's own deliverable made it on the first pass.
This is the same gap `testing.guard-derives-population-from-source` already flagged one directory
over — `MARKED_JOBS` in `test_ci_dropped_step_guard.py` is a hand-written mirror of those same
required contexts, annotated with a date. A dated comment is a claim about the past. Two
independent guards now depend on that configuration; one of them checks it.
**The deliverable's own population was wrong three times, and that is the most transferable part of
this record.** The inventory is the detector, so its population *is* the guard. Round one filtered
the scope on an outbound-network token list, which omitted `git fetch` — this repo's commonest
remote read — so a hook that fetches `origin/main` and derives a push decision was invisible.
Round two dropped the filter but used non-recursive `glob`, so four nested files stayed out, one
of them calling a live ErsatzTV API and acting on the reply. Round three used `rglob`, which is
recursive and therefore also enumerated `.husky/_/` — untracked, gitignored shims that `npm ci`
generates — leaving the guard **red on every developer checkout and green in CI**, which never
runs `npm ci`. A guard that fails everywhere except where it runs teaches its readers to ignore
it, which is worse than no guard at all.
Every round shipped with an argument for why the traversal was sufficient, and every argument was
wrong the same way. The fix that finally held was not a better traversal: it was **deriving the
population from `git ls-files`**. The filesystem is not an authoritative source — it reports build
output, editor droppings and whatever else is on disk, and it differs per machine. The index is
authoritative, versioned, identical for CI and every checkout, and excludes untracked generated
files by construction rather than by an exclusion list somebody has to maintain. So the
generalisation is the one `testing.guard-derives-population-from-source` already states, applied
one level up: when a guard enumerates a population, ask **what is the authoritative list of these
things** — and if the answer is "whatever the walk finds", the guard is not finished, however
carefully the walk is written.
**Grade down before you argue.** Three cold-review rounds demoted row after row — the scheduled-merge
path, both merge-consent head/base reads, the file enumerator, every registry-tag row — and in
each case the row asserted a property the code did not have while the code beneath it was fine.
Only **three** rows survive as `PINNED`, out of roughly seventy. That is the honest finding about
this class in a tooling codebase: almost nothing that talks to a remote service is genuinely
pinned, most of it is bounded by an argument, and the argument is what has to be written down.
The denominator is deliberately approximate, and that is a finding rather than laziness. Written
as an exact "N of M" it went stale **three times in three rounds** — twice because a demotion
landed after the count, once because splitting one row into two moved M inside the very commit
that cited it. A figure that changes whenever the artifact it describes is edited is a second copy
of that artifact, and this repo already knows what to do about a hand-maintained mirror: give it
an equality check or stop maintaining it. `docs/guard-inventory.md` took the first route because
its counts are the point; a rationale record takes the second, because the load-bearing claim here
is "almost nothing is pinned", not any particular integer. A row that overstates is worse than a
row that admits a gap, because this repo's own record is that a guard described as sound stops
being re-examined.
**What this record does not claim.** The inventory grades *files*, so it cannot see an existing file
growing a second unpinned read — the sites-in-code limit tracked in #777. And nothing verifies
that a row marked `PINNED` is telling the truth. Both residues are stated in
`docs/remote-state-inventory.md` rather than papered over, because a guard described as sound
stops being re-examined, which is the failure mode this whole family is made of.
@@ -1,48 +0,0 @@
---
key: release.verdict-writes-status-before-comment
title: '2026-08-22 — the verdict STATUS is written before the verdict COMMENT, so the only reachable half-state is the safe one (#792)'
status: active
since: '2026-08-22'
supersedes: none
superseded-by: none
rule: '`scripts/post-review-verdict.sh` writes the sha-bound `review-verdict/h10` commit status FIRST and the human-readable `Review-verdict:` comment SECOND. Every refusal path still refuses (fail-closed, unchanged) and exits non-zero, and none of them may leave a verdict comment behind. An orphaned comment is therefore PREVENTED rather than tolerated. If the comment write fails after the status was written, that is an error too, but it degrades to an `ask` at the merge gate rather than to an apparent grant.'
signals: 'verdict comment without a status, half-posted verdict, orphaned Review-verdict comment, exit code lies, post-review-verdict exits 0 · paths: `scripts/post-review-verdict.sh`, `scripts/tests/test_post_review_verdict.py`, `.claude/hooks/pretooluse-merge-consent.sh` · issues: #792, #622, #632, #778'
mechanics: '`scripts/tests/test_post_review_verdict.py::test_the_status_is_written_BEFORE_the_comment`, `::test_no_refusal_leaves_a_VERDICT_COMMENT_standing_in_for_the_status` (every refusal mode), `::test_every_path_that_writes_NO_STATUS_exits_non_zero`, `::test_a_failed_COMMENT_after_a_written_status_is_still_an_error`'
---
The script has two writes and they are not equal. The **status** is the gate — a required context on
`main`, bound to one sha. The **comment** is the artifact a human reads, and the merge hook's
condition (c). Writing the comment first meant that every refusal between the two writes left a PR
carrying `Review-verdict: MERGEABLE @ <head>` with no status behind it: an artifact that reads as
granted consent, produced by the very run that refused to grant it. The refusals are correct and are
not what changed (`ci.verdict-write-retarget-fence` — the fence must keep refusing when it cannot
bind safely); what changed is which write survives a partial failure.
Ordering settles it without a rollback, and rollback is the option not taken: deleting or annotating
the orphaned comment needs a Gitea call, and the refusals it would compensate for are frequently
*caused* by Gitea being unreachable, so the compensating write is unavailable exactly when it is
needed. Ordering costs nothing and cannot fail to apply.
The two surviving half-states are asymmetric, and that asymmetry is the whole justification:
- comment, no status → the hook's condition (c) classifies a positive verdict, the operator sees
consent, and only the required check stands between that and a merge. Fail-open in appearance.
- status, no comment → the hook has no verdict for this head to classify, which it resolves as
**ask**. Fail-closed, visible, and cured by re-running the command. The qualification, because
the hook reads the whole comment history rather than this run's write: if a positive verdict for
the SAME head already exists, condition (c) is satisfied by it and the hook may grant — which is
correct, since that comment covers this exact sha. A stale or negative verdict yields deny. So
`ask` is the outcome when the current head has no verdict comment, and nothing here can produce a
grant over a head no one reviewed.
**#792's premise about the exit code was wrong, and is corrected rather than repeated.** The issue
reported the script printing its refusal and exiting 0. Re-measured on the tree that carries #632's
fence, every no-status path exits NON-ZERO (`die` exits 1, the usage path 2, a failing `jq` its own
status, a signal 128+n) — eight refusal modes are now driven through the real entry point
and asserted, and those assertions pass against the predecessor as well, which is how we know the
defect was never in the script. The observed 0 came from the invocation around it (a pipeline reports
its last command's status, not the script's). The exit-code contract is asserted anyway: it was true
by convention, held by one shared `die` helper, and nothing had ever executed it.
Read with `release.verdict-status-check` (why the status, not the comment, is the gate) and
`release.review-verdict-gate` (the comment convention itself).
@@ -1,87 +0,0 @@
---
key: testing.deny-path-at-production-config-value
title: '2026-08-21 — A config-gated guard is tested on its DENY branch at the value production actually runs (#779)'
status: active
since: '2026-08-21'
supersedes: none
superseded-by: none
rule: 'Where behaviour is gated by a configuration value, an environment variable or a credential, the test matrix covers every value the surface will actually meet — the setting ABSENT, the setting at its PRODUCTION value, and each explicit opt-out — and it asserts the DENY branch, not only the allow branch. A fixture that OMITS the field tests the default and nothing else, so a fail-open reachable only through the configured value stays invisible however many tests are green (#756: thirty of them were). Two corollaries carry most of the weight. FIRST, a hand-written test double that is HANDED the resolved flag proves the CONSUMER reacts to it and says nothing about the line that DERIVES it; if no test constructs the real provider, a mistyped configuration key or a flipped default is unobservable to the whole suite. SECOND, the dangerous cell is whichever one production occupies, which is not always the explicit one: when the shipped default IS the permissive branch the absent case is the production case (#280''s null `Api:WriteKey`), and when the default is fail-closed the configured value is the one nothing has exercised. Enumerate the cells before deciding which to test; do not infer the risky one from which is easier to write. This rule is NOT mechanically enforced and deliberately so — deciding whether a given test used the production value is a string predicate over test source, the class this repo has withdrawn twice.'
signals: 'deny path at the production value · fixture omits the field tests only the default · fail-open by default · parametrise the whole config matrix · absent versus configured versus opt-out · a fake that is handed the flag never runs the line that derives it · nobody constructs the real provider · `Api:WriteKey` · `Api:RequireKeyForReads` · `ERSATZTV_ALLOW_WRITES` · `memory_pressure` absent · `xxd` absent on the runner · paths: `ErsatzTV/Services/ApiKeyProvider.cs`, `ErsatzTV.Tests/Services/ApiKeyProviderTests.cs`, `ErsatzTV.Tests/Filters/ApiAuthorizationFilterTests.cs`, `docs/guard-inventory.md` · issues: #779, #773, #756, #768, #751, #647, #282, #280'
mechanics: 'Detector F of `docs/defect-shapes-773.md` §4. No CI check implements it; the population of config-gated guard FILES is already derived and machine-checked by `scripts/tests/test_guard_inventory.py`, and this rule is the judgement layered on top of that population.'
---
**The rule exists because the green suite is the symptom, not the reassurance.** #756's fixture
omitted a field. The unset behaviour was correct, thirty tests said so, and a fail-open reachable
only through the *production* value sat behind them untouched. Nobody skipped a test; they tested
the cell that was easy to construct and read the result as coverage of the setting.
**The three cells, because naming them is most of the fix.** A gated surface has a value that is
absent, a value production sets, and one or more explicit opt-outs. Which cell is dangerous is a
property of the surface, not a constant, and this is where the reasoning usually goes wrong:
- **Default permissive.** #280 — API writes fail *open* when `Api:WriteKey` is null or empty, and
null/empty was the shipped default. Here the absent cell *is* production, and a test that
configures a key to exercise the guard has stepped off the dangerous cell to do it.
- **Default fail-closed.** `Api:RequireKeyForReads` defaults to `true`. Now the absent cell is safe
and the configured cells are the unexercised ones.
**The variant this repo was actually carrying, found by #779's audit and worth more than the
principle.** Every assertion about the read-gating posture ran through a hand-written
`FakeApiKeyProvider` that is *handed* the boolean — `ApiAuthorizationFilterTests`,
`ApiKeyEndpointRequiresKeyTests`. Those tests are good ones: they cover the deny branch, at
`requireKeyForReads: true`, which is the production value. And they could not see a defect in
`ApiKeyProvider` at all, because **no test in the repository constructed it.** The single line
deriving the posture from configuration — `configuration.GetValue(RequireKeyForReadsConfigurationKey,
true)` — had never been executed by a test. Mistype the key, flip the default, and every one of
those green deny-path assertions stays green while the shipped build serves anonymous reads.
That is the shape to internalise: *the fake was accurate, the fixture was at the production value,
and the coverage was still absent* — because the double stood exactly where the untested code was.
Ask not only "which value did the test use" but "which code did the value flow through".
**Fixed here**, by constructing the real provider across the matrix in `ApiKeyProviderTests`:
absent, `true`/`True`/`TRUE`, `false`/`False`, and — the cell the first draft of this record forgot
while claiming "the whole matrix" — a present-but-non-boolean value. `ConfigurationBinder` returns
the default ONLY for a null section value, so `""`, `1` or `yes` reach `BooleanConverter` and throw
`InvalidOperationException` at construction (measured). That is fail-CLOSED: an operator who writes
`Api__RequireKeyForReads=` with nothing after it in a compose file gets a refusing app, not silent
anonymous reads. It is pinned so a later switch to a lenient `TryParse` cannot turn a typo into a
posture change. `Api:WriteKey` is set in those cases only so
`ResolveKey` returns before touching the live config volume; that is a test-isolation detail and
deliberately not their subject.
**What the mutation proof here does and does not show, because the honest version is weaker than
the headline.** Flipping the shipped default `true` -> `false` reddens
`Read_Gating_Is_Required_When_The_Setting_Is_Absent` and only that test. That establishes the
*absent* cell is load-bearing. It also shows the three explicitly-configured `true`/`True`/`TRUE`
cases — the cells this rule is named for — are NOT independently load-bearing against that mutation:
a mistyped configuration key is caught, but by the `false` cases, not the `true` ones. A mutation class that would
redden `"True"`/`"TRUE"` alone is a hand-rolled case-sensitive parse replacing `GetValue`. They are kept as behaviour coverage of the real binder rather than deleted, and the
distinction is written down instead of being smoothed over: a matrix that is *complete* is not
thereby a matrix in which every cell *discriminates*.
**Why no check enforces this.** Deciding whether a test exercised a production value means reading
test source and judging intent — a string-matching predicate over code, which
`docs/defect-shapes-773.md` §4 argues against and which this repo has withdrawn twice after
round-churn (#629, #774). What *is* mechanical already exists: `test_guard_inventory.py` derives the
guard-file population and asserts set equality, so this rule has a maintained list to be applied to.
The judgement stays with review, and saying so is the honest position rather than shipping a
keyword matcher that would manufacture the confident-but-empty coverage the rule is about.
**Environment counts as configuration.** The permissive branch is often reached by a tool being
absent rather than a setting being wrong, and it looks identical from inside. `pretooluse-bom-guard.sh`
detected BOMs with `xxd`, which ships with vim and was **absent on the Linux CI runner**, so the
comparison never matched and every BOM was allowed in silence while the hook fired on every commit
(`docs/guard-inventory.md`). `pretooluse-agent-ram.sh` has the same construction today —
`memory_pressure` is macOS-only and its absence yields `exit 0`, allowing unbounded fan-out — and its
deny branch is exercised by no test. It is left to #785, which owns mutation proofs for the unproven
guards; splitting one guard's proof across two issues is how a row ends up claiming coverage twice
and holding none.
**Audit residue, so the gaps are tracked rather than implied closed.** Of the config-gated guards
enumerated from `git ls-files` over `.claude/hooks/`, `.husky/`, `scripts/` and `.gitea/workflows/`:
the C# auth surface is now covered end to end; `docker-build.yml`'s dropped-step fail-open is covered
by `test_ci_dropped_step_guard.py`; and the untested permissive branches that remain are all hook
`exit 0` escape hatches (`ETV_ALLOW_DIRTY_PUSH`, `ETV_SKIP_REBASE_CHECK`, absent credentials in
`prepush-donewhen.sh`, absent `python3` in `decisions-guard.sh`) — each already a row in
`docs/guard-inventory.md` with proof `NONE`, and so already inside #785's scope.
@@ -1,257 +0,0 @@
---
key: testing.full-replace-asserts-field-list
title: '2026-08-21 — A full-replace path asserts its COMPLETE field list against the DTO, and reconciles by id where child state exists (#779)'
status: active
since: '2026-08-21'
supersedes: none
superseded-by: none
rule: 'Any path that writes a WHOLE entity or a WHOLE child collection — a PUT-replace handler, a hand-built request object, a test comparer standing in for one — derives its field list from the authoritative type and asserts SET EQUALITY against it, rather than enumerating the fields by hand. A hand-written list is correct on the day it is written and structurally unable to report the day it stops being: the field that drifts is the one nobody wrote a line for, so no amount of care in the existing lines can reach it. The failure is silent by construction — a full replace with a field omitted returns HTTP 200 and destroys that field''s value (#754 drifted from a 28-property DTO by one and cleared it; the symptom arrived hours later as missing pixels). SECOND CLAUSE, separable from the first: where a replaced child row carries state keyed to its identity — progression, ordering, an enumerator position — the handler RECONCILES BY ID rather than delete-and-reinsert, because reinsertion silently resets state a client never asked to touch (#252: a schedule PUT reset fill-group progression; #500: a dedup fix became permanent data loss because the add filter and the remove filter used different keys, so the two halves must agree on the key). Delete-and-reinsert is acceptable ONLY where no such state exists, and that emptiness is a fact about today''s schema that a later feature can silently invalidate — so record it where the handler is, dated, rather than leaving it to be re-derived. The canonical worked example is `ToolCatalogTests.Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields`, which reads the accepted fields from the generated OpenAPI document and compares both directions. ON THE SPA SIDE the same rule is enforced by the TYPE SYSTEM rather than by a test: a full-replace body is built as `Complete<T>` (`web/src/api/completeRequest.ts`), a mapped type that makes every member of a generated request type required, so a builder that omits one fails `npm run typecheck`. Annotate BOTH the API-wrapper parameter (so later callers inherit it) AND each construction site including every `.map` callback return type, because the excess-property check that catches a PHANTOM field fires only on a fresh literal in a contextually typed position and a generic `.map` callback is not one. Do not infer from "most builders already typecheck" that the gap is closed: a member is omittable exactly when it is absent from the schema `required` array in the ASP.NET-produced OpenAPI document, and two live cases (#807) sat unchecked inside a large majority of checked ones.'
signals: 'full replace asserts its field list · hand-maintained mirror drifts by one field · reconcile by id not delete and reinsert · 200 and the field is gone · add filter and remove filter must share a key · fill-group progression reset by a PUT · derive the comparer from the DTO · a lossless round-trip test that is itself a hand-copied list · anti-vacuity PIN not a floor on a reflective walk · stale exemption must still name a real property · a complete comparer over a hand-written fixture · universal negative in a record is a trap · the SPA builds a full-replace body as `Complete<T>` · a member is omittable only when it is absent from the OpenAPI `required` array · excess-property checking does not fire inside a generic `.map` callback · a comment warning about a silent reset is not a check · `weight` · `qsvPreferNativeDecoder` · paths: `ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs`, `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs`, `web/src/api/completeRequest.ts`, `web/src/api/completeRequest.guard.test.ts` · issues: #807, #779, #773, #757, #754, #500, #252'
mechanics: 'Detector G of `docs/defect-shapes-773.md` §4. Enforced per-site by a reflective comparison over the DTO plus a written-down count pin, not by a repo-wide check — see the record body for why a global one is not proposed. The exemption set is empty today; the machinery guarding exemptions remains for the first one that earns its place.'
---
**This is `testing.guard-derives-population-from-source` applied to a write path, and it is worth its
own record because the population here is not obviously a population.** A guard asserting
completeness over an enum or a tool catalog visibly has members to enumerate. A PUT handler and its
test look like ordinary code with a lot of fields in them — and the fields *are* the population.
#754 is what that looks like when nobody notices: a hand-maintained wrapper one field short of a
28-property DTO, returning 200, clearing the value, and surfacing hours later as missing pixels.
**The repo already had the right answer in one place.** `ToolCatalogTests`'s write-tool test reads
the accepted request-body fields out of the generated OpenAPI document, walks the tool catalog
unfiltered, and compares both directions — an accepted field a tool never declares and a declared
field the API does not accept are opposite defects and are reported as such. That test is the fix
for #754/#757 and it is the shape to copy, not the prose above it.
**Where #779's audit found the mechanism still live: in the test whose job was to catch it.**
`ScheduleItemResponseRoundTripTests` is the release gate for the flat schedule-item DTO — a
GET → map → PUT → GET fixed point asserting the round trip is lossless. Its comparison was
`AssertSemanticallyEqual`, a hand-written run of `b.X.ShouldBe(a.X)` lines. On audit it covered every
property but `Id` — so it was *complete*, and had no way to say so: add one more field to
`ScheduleItemResponseModel` and it is compared by nobody, the round trip drops it, and the test named
for losslessness stays green. The gate against the drift was itself the drift, one altitude up.
It now derives the property set by reflection over the DTO, and the exemption set is **empty**
which is the most useful thing this exercise produced. The first version exempted `Id`, reasoning
that the PUT replaces the item set so B's rows are new rows with new ids. That is the opposite of
what the endpoint does: the request forwards each `Id`, so the handler takes its id-based reconcile
path and updates rows in place. The exemption was discarding an assertion on a rationale that
contradicted the code — the failure this record is about, committed inside the fix for it.
Two further details. Every exempted name is asserted to still *exist* on the record, because a stale
exemption exempts nothing while reading in review as a considered decision. And the anti-vacuity
check is a **pin** (`ShouldBe(55)`), not a `>=` floor: a floor lets properties vanish silently, which
is the one-sided version of the both-directions rule this record invokes.
(`testing.guard-derives-population-from-source` endorses a floor on its canonical example; this is
strictly stronger, not a contradiction of it.) Comparing against the
reflected count minus exemptions would be tautological — both sides come from the same reflection —
so the number is written down and must be bumped deliberately. It was obtained by raising the pin and
reading the failure, not counted off the source.
**The `Id` exemption, and the paragraph that replaced it three times.** Two mutations of the
round-trip fixture, both executed:
| Mutation of `ToReplaceCommand` | Result |
|---|---|
| `null` for **every** id | GREEN — `requestIds.Count == 0` takes the positional fallback, which also reuses each row |
| `null` for **index 0 only** | RED, `Id differs` — the id-based branch deletes the unreferenced row and inserts the id-less item as new |
So comparing `Id` discriminates, and the exemption was discarding a real assertion. It is still not a
substitute for `ReplaceProgramScheduleItemsReconcileTests`
(`Reorder_ById_Should_Move_State_With_The_Logical_Item_Not_The_Slot`,
`Insert_ById_In_Middle_Should_Keep_Existing_Ids_And_State`), which pass real ids and pin that state
moves with the logical item rather than the slot.
**The transferable part is not the `Id` detail.** This paragraph was written four times. Every draft
paired a correct observation with a confident causal story, and three of those stories were
contradicted by the code — including one that generalised a single measured case into a universal
("no id-less mutation can redden it") that a *partial* payload falsifies. The rule that survives:
**state the measurement and the code path you actually read; do not generalise from one executed case
to a class of cases, and do not explain a mechanism you did not measure.** A decision record is
exactly where such a story does the most damage, because it reads as checked.
**A completeness guarantee on the COMPARER is not one on the FIXTURE.** The seed builder feeding this
round trip is still a hand-written list. A new nullable field is now reflected and compared — as
`null` against `null` — until someone also seeds it, so the round trip can still drop it while this
test stays green. Detector G is satisfied for the comparison, not for the fixture; that residue is
real and is named here rather than left implied.
**On the second clause, the audit's finding was a negative one — and the first version of it was too
strong, which is exactly why negatives are dangerous to record.** Four handlers — playlist items,
block items, deco-template items, template items — `RemoveRange` their children and rebuild, the
shape #252 was fixed for. The claim written here first was that no persisted state is keyed to those
item ids. That is false, and the counterexamples live in the same subsystem the record cites
(verified 2026-08-21):
- `PlayoutItem.SchedulingContext` is a persisted column holding a serialized
`BlockSchedulingContext(BlockId, **BlockItemId**, Enumerator, Seed, Index)`.
`ReplaceBlockItemsHandler` deletes and reinserts, so the stored `BlockItemId` no longer refers to
the row it was written for. `ProcessSchedulingContextHandler` looks it up: it either resolves
nothing and falls back to `new BlockContextBlockItem(id, null, null)`, or — if the id was reused by
a different item — resolves to the WRONG one. Which of the two happens was not measured, so the
claim here is only that the reference is orphaned; the second case is the worse one and is the
reason not to write this off.
- `PlayoutItem.GuideGroup` is set to `effectiveBlock.TemplateItemId` and persisted, while
`ReplaceTemplateItemsHandler` deletes and reinserts. `GuideGroup` is the XMLTV grouping key, so a
reused id colliding with one already persisted on older items is a HAZARD rather than a
demonstrated consequence — unmeasured. Note the block path groups by
`{GuideStart, GuideFinish, GuideGroup}` together (`ChannelGuideProjector.ProjectBlock`), a plain
`GroupBy` and NOT contiguity-sensitive, so two non-adjacent runs sharing the triple would merge;
the contiguity logic is the flood projector, which never sees a `TemplateItemId`-derived
`GuideGroup`.
The accurate statement is narrower: **no progression or enumerator state is keyed to those ids**, so
the replace does not reset scheduling position — but two persisted *diagnostic/grouping* fields do
embed them and are orphaned by it. Degradation, not data loss. The evidence originally offered
(`BlockKey` keys off parent id plus `DateUpdated` ticks, never `BlockItem.Id`) is true and supports
only that narrower claim. The other full-replace handlers reconcile by id rather than delete-and-reinsert:
`ReplaceProgramScheduleItemsHandler` (#252's scoped positional/no-op reconcile),
`ReplacePlayoutAlternateScheduleItemsHandler` (over `ProgramScheduleAlternate`) and
`ReplacePlayoutTemplateItemsHandler` (over `PlayoutTemplate`) — neither of the last two touches the
`TemplateItem` rows listed among the four above, despite the names.
Only the FIRST of those three is demonstrably protecting identity-keyed state: `PlayoutScheduleItemFillGroupIndex`
holds an FK to `ProgramScheduleItemId`, whereas `PlayoutTemplateId` and `ProgramScheduleAlternateId`
appear nowhere in the solution, model snapshot included (verified 2026-08-21). The other two reconcile
by id for their own reasons, and attributing that to progression state would be a causal story the
schema does not support.
The general lesson is the one this record nearly failed to learn: a universal negative in a durable
document reads as a checked fact and talks the next reader out of checking. Bound it to what was
actually enumerated, and date it.
**Why this is not a repo-wide mechanical check.** Finding "every full-replace path" means deciding
which PUT/POST handlers replace a whole collection versus patch fields — a judgement about intent,
and the population is *sites in code*, which `testing.guard-derives-population-from-source` explicitly
scopes out as needing find-all-references tooling this repo does not have (#777). A global lint would
be a name matcher over handler classes ending `Replace…Handler`, which misses the ones that do not
and flags the ones that do it correctly. Per-site derivation, applied when a full-replace path is
touched, is the enforceable version.
**The SPA half.** The residue this record recorded was that the SPA builds request objects
field-by-field in six screens (`normalizeForSave` and its equivalents), so an *optional* field added
to a DTO and not carried through would compile clean and drop silently.
**The current statement.** A member is omittable exactly when it sits outside its schema's
`required` array; nullable properties usually emit as required-and-nullable (`"name": null | string`),
so most builders are checked and the gap reads as closed on inspection. It is not. Two full-replace
PUTs carried a live optional member (verified by execution 2026-08-22, by deleting the field and
watching `npm run typecheck` stay CLEAN):
| schema | optional member | wrapper | consequence of the drop |
|---|---|---|---|
| `MultiCollectionItemRequest` | `weight` | `updateMultiCollection` (PUT) | every weight resets to 1 on save |
| `UpdateFFmpegProfileRequest` | `qsvPreferNativeDecoder` | `updateFFmpegProfile` (PUT) | the setting reverts to its default |
**The framing this replaced, recorded so it is not re-adopted**: that the mechanism was *latent*
true of the request schemas the six named builders target, which were the ones checked, and
reported as a property of the SPA. (No count is given: the earlier drafts carried one, and no
natural cut of "the schemas those builders target" reproduces it.) A
conclusion verified across the cases examined and then stated about the whole is the shape this
corpus keeps recording, and it read as checked. `MultiCollectionsScreen` carries a prose comment
warning about precisely that weight reset; a comment is not a check, and neither is a boundary
drawn around the sample.
**`Complete<T>`** (`web/src/api/completeRequest.ts`) maps a request type so every member is
required, making an omission a hard error however the schema was modelled. THE RULE is to apply it
in two places: at the full-replace API-wrapper boundary, so later callers inherit it without knowing
it exists, and at each construction site, which is what keeps TypeScript's excess-property check
(the *phantom* direction) alive.
That is the rule, not a claim about how much of the tree currently follows it. Nothing enforces the
second half — #820 — so any sentence here asserting present coverage would be falsifiable by one
`tsc` run and would go stale on the next screen anyone adds. Two wrappers annotated under #807
initially shipped with unannotated construction sites for exactly that reason. To find out what is
actually annotated, read the code; do not read a count here.
**Why the second half of the rule is the one that gets skipped.** The wrapper annotation catches a
MISSING member anywhere. The PHANTOM direction — a field the schema does not accept — relies on
TypeScript's excess-property check, and that fires only on a fresh object literal in a contextually
typed position. A literal returned from a generic `.map` callback is not one, because `map<U>`
infers `U` from the callback rather than from the target element type. Measured on the pre-#807 tree by injecting a
phantom property at each construction site then present: some rejected it and some accepted it,
and among those that accepted, three contained neither a spread nor an inferred local — so "it has
no spread" is not a reason to think a site is checked. No denominator is given: what counts as a
"construction site" is not derived from anything, the figure was already wrong once on this branch,
and the transferable finding is the mechanism, not the tally.
Measured, not argued (2026-08-22): injecting an optional member into `ScheduleItemRequest` reddens
`normalizeForSave` with `TS2741` under `Complete<T>` and compiles clean without it; deleting
`weight` from `MultiCollectionsScreen.toItemRequest` now reddens and previously did not. The proof
ships as `@ts-expect-error` cases in `web/src/api/completeRequest.guard.test.ts`, re-executed on
every `npm run typecheck` (a marked CI step).
**The boundary is DERIVED, and the two attempts to write it by hand are why.** The rule this
record states — derive the population, never enumerate it — took two rounds to apply to the record's
own coverage boundary, each time failing the same way: a schema sorted on its NAME rather than on
what its endpoint does.
| round | the hand-written form | what it missed |
|---|---|---|
| 1 | a prose sentence exempting "create/update" | `updateMultiCollection` and `updateFFmpegProfile` are full replaces — both were LIVE silent drops |
| 2 | a table, written to replace that sentence | `ArtworkContentTypeModel`, because `…Model` reads as a response model. It is reachable from the full-replace `PUT /channels/{id}` |
Two misses from one mechanism, so the mechanism went instead of the list getting a third patch.
`scripts/tests/test_optional_request_members.py` now DERIVES the population every run from
`ErsatzTV/wwwroot/openapi/v1.json` — every schema carrying a property outside its `required` array
that is **transitively** reachable from ANY operation's request body, plus request bodies declared
inline rather than by `$ref` — and asserts set equality in both directions against a registry of
per-schema dispositions. Its own reach is bounded by what its composition resolver handles, and the
resolver's branches are pinned by constructed-schema tests rather than by the one shape today's
document happens to contain; read that file for the current boundary rather than a summary here. Transitivity is load-bearing:
`MultiCollectionItemRequest` and `ArtworkContentTypeModel` are both nested, so a check reading only
top-level bodies would have reproduced both misses.
The split follows `testing.guard-derives-population-from-source`: the POPULATION is derived, the
DISPOSITIONS are the SCOPE — a reviewed policy choice per schema, legitimately hand-written, and
forced to exist by the equality assertion. A new optional member in a named component schema
reachable from a request body, or in an inline request body, now fails that test until someone
writes down what should happen about it. The dispositions themselves
live in that file rather than here, so there is one copy.
**On this record's own pin-vs-floor argument, applied to that guard.** The `Id` discussion above
argues that an anti-vacuity check must be a PIN and not a `>=` floor, because a floor lets members
vanish silently. `test_optional_request_members.py` uses floors, and that is not a quiet exception:
a pin on "how many schemas are reachable from a request body" would be a pin on the size of the
whole API, red on every unrelated endpoint added. What replaces the pin's strength is the
both-directions set equality — a schema that vanishes from the population reports as PHANTOM, which
is exactly what the pin existed to catch — plus a planted-member test that is red whenever the walk
stops seeing nested or `oneOf`-referenced schemas. Measured 2026-08-22: the floors alone do NOT
catch a partially broken walk (deleting the transitive step leaves them satisfied); the planted
test and the equality assertion do. The floors are the crude backstop against a parse that reached
nothing at all, and the guard's docstring says so rather than letting them read as coverage.
**One disposition is worth stating here because it bounds this record's own rule.** `Complete<T>`
must NOT be applied to a schema whose optional members are computed get-only properties:
`ArtworkContentTypeModel`'s `IsExternalUrl` / `HasContentType` / `UrlWithContentType` are derived
from `Path` and are never deserialized, so a client omitting them drops nothing — while annotating
the site would force a caller to fabricate server-computed values in an outbound request. The test
that decides the column is **does this write replace a whole entity or collection, and can the
member actually carry a stored value**, not whether the wrapper is named `update…` or `replace…`.
**The wrapper-boundary rationale is a DIRECTION, not a claim about today's coverage.** Some
full-replace PUT wrappers in `web/src/api/*.ts` carry `Complete<>` — the ones reachable from a
schema that can drop a member, plus the six builders #807 set out to fix — and most do not. No
figure is given here deliberately, and neither is a characterisation of WHICH ones: nothing
derives either, and a hand-maintained description of a code population is the thing this record
argues against. (An earlier draft said "the ones reachable from a schema that can drop a member,
plus the six builders #807 set out to fix" — five annotated wrappers fall outside both sets. A set
phrased in words rots exactly like a count.) No reproduction command is offered either: isolating
"full-replace PUT wrappers" from a grep needs the judgement about intent this record already says a
matcher cannot make. For the unannotated wrappers the protection is still the
contingent kind this record complains about: it holds only while their properties stay inside
`required`. What makes that survivable rather than a re-run of the same mistake is that the
contingency is now MONITORED — the derived test above reddens the moment one of them acquires an
optional member — instead of being assumed. Annotating the rest is cheap and should happen when
each is next touched.
**Residue, named rather than implied closed.** `Complete<T>` proves its own semantics but NOT that
it is applied: reverting one screen to its pre-#807 form leaves the guard green, because the
population of construction sites is derived from nothing. The repo already owns the tool for that
`web/src/api/pageSizeScan.ts` (TypeScript compiler API) plus a registry cross-checked in both
directions — and building it is tracked in #820. Until then this convention is enforced by review,
which is the weaker thing this record exists to warn about. Separately: `Complete<T>` is shallow,
so a new nested item request type needs its own annotation; and requiring a field to be *named* is
not the same as requiring it to be *populated* correctly.
@@ -5,8 +5,8 @@ status: active
since: '2026-08-13'
supersedes: none
superseded-by: none
rule: 'A guard that asserts a COMPLETENESS property enumerates its population from a machine-readable authoritative source — the enum, the generated OpenAPI document, the parsed workflow YAML, the provider list — and asserts SET EQUALITY in BOTH directions against it. It may not narrow that population with a filter, a `Where`, a `grep` or an early `continue` before the assertion, because a filter cannot see the member that is MISSING: the member whose absence is the defect is precisely the one the predicate excludes. A hand-written literal list of members is the same defect in slower motion — a filter frozen at authoring time, correct on the day it was written and unable to report the day it stopped being. Two boundaries bound the rule rather than weaken it. FIRST, filtering to select the SUBJECT of a PER-MEMBER property is legitimate and is not this defect: the excluded members satisfy the property vacuously, so the filtered walk and the whole walk assert the same thing (`ToolCatalogTests.Every_Query_Parameter_Should_Be_A_Declared_Property` filters to tools that declare query parameters, and a tool declaring none has nothing to check). The defect is filtering the population before a COMPLETENESS claim, which is what makes an absent member unrepresentable (#757 filtered on `QueryParameters is {Count: > 0}` and so could not see a tool that should have declared one and did not). SECOND, a population of VALUES always has an external authoritative source and this rule applies directly; a population of SITES IN CODE has no such list, needs find-all-references tooling, and is tracked separately in #777 — do not stretch a set-equality assertion over it. Distinguish the guard SCOPE (which subsystems it covers — a reviewed policy choice, legitimately hand-written) from the guard POPULATION (the members inside that scope — always derived). When the scope itself MIRRORS an authoritative source, the mirror needs its own equality check or a dated staleness marker, or the guard is complete within a scope that has silently gone stale. The canonical worked example in this repo is `ToolCatalogTests.Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`; the canonical residual gap is `MARKED_JOBS` in `scripts/tests/test_ci_dropped_step_guard.py`. WHEN THE POPULATION IS FILES (#806), the authoritative source is the GIT INDEX and never a filesystem walk. A walk is not merely a weaker enumerator, it answers a question about the MACHINE rather than about the repo: it reports build output, generated shims and editor droppings, and it differs between CI and every checkout, so the same guard asserts a different population in each place. Derive with `git ls-files`, take direct children only unless a nested population is stated and wanted, and assert existence rather than filtering on it, because filtering is what makes a missing member unrepresentable. This is an instantiation and not a blanket rewrite: the question per guard remains whether it makes a COMPLETENESS claim over TRACKED files, and a walk that assembles a fixture or selects the SUBJECT of a per-member property stays a walk with its reason written down.'
signals: 'guard derives population · set equality both directions · never filter never sample · a filter cannot see the missing member · hardcoded list is a frozen filter · missing vs phantom · unreachable vs phantom query parameter · anti-vacuity count guard · accumulate drift do not fail fast · scope versus population · dated mirror of branch protection · `status_check_contexts` · `MARKED_JOBS` · values versus sites-in-code · paths: `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `scripts/tests/test_ci_dropped_step_guard.py`, `web/src/api/pageSizeCallSites.guard.test.ts` · file population from the git index · never a filesystem walk · untracked shims redden every checkout · `.husky/_/` · direct children not rglob · paths: `scripts/tests/tracked_files.py`, `scripts/tests/test_guard_populations_derive_from_git.py`, `docs/guard-inventory.md` · issues: #806, #778, #774, #773, #757, #671, #650, #644, #633, #616, #503, #403'
rule: 'A guard that asserts a COMPLETENESS property enumerates its population from a machine-readable authoritative source — the enum, the generated OpenAPI document, the parsed workflow YAML, the provider list — and asserts SET EQUALITY in BOTH directions against it. It may not narrow that population with a filter, a `Where`, a `grep` or an early `continue` before the assertion, because a filter cannot see the member that is MISSING: the member whose absence is the defect is precisely the one the predicate excludes. A hand-written literal list of members is the same defect in slower motion — a filter frozen at authoring time, correct on the day it was written and unable to report the day it stopped being. Two boundaries bound the rule rather than weaken it. FIRST, filtering to select the SUBJECT of a PER-MEMBER property is legitimate and is not this defect: the excluded members satisfy the property vacuously, so the filtered walk and the whole walk assert the same thing (`ToolCatalogTests.Every_Query_Parameter_Should_Be_A_Declared_Property` filters to tools that declare query parameters, and a tool declaring none has nothing to check). The defect is filtering the population before a COMPLETENESS claim, which is what makes an absent member unrepresentable (#757 filtered on `QueryParameters is {Count: > 0}` and so could not see a tool that should have declared one and did not). SECOND, a population of VALUES always has an external authoritative source and this rule applies directly; a population of SITES IN CODE has no such list, needs find-all-references tooling, and is tracked separately in #777 — do not stretch a set-equality assertion over it. Distinguish the guard SCOPE (which subsystems it covers — a reviewed policy choice, legitimately hand-written) from the guard POPULATION (the members inside that scope — always derived). When the scope itself MIRRORS an authoritative source, the mirror needs its own equality check or a dated staleness marker, or the guard is complete within a scope that has silently gone stale. The canonical worked example in this repo is `ToolCatalogTests.Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`; the canonical residual gap is `MARKED_JOBS` in `scripts/tests/test_ci_dropped_step_guard.py`.'
signals: 'guard derives population · set equality both directions · never filter never sample · a filter cannot see the missing member · hardcoded list is a frozen filter · missing vs phantom · unreachable vs phantom query parameter · anti-vacuity count guard · accumulate drift do not fail fast · scope versus population · dated mirror of branch protection · `status_check_contexts` · `MARKED_JOBS` · values versus sites-in-code · paths: `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `scripts/tests/test_ci_dropped_step_guard.py`, `web/src/api/pageSizeCallSites.guard.test.ts` · issues: #774, #773, #757, #671, #650, #644, #633, #616, #503, #403'
mechanics: 'Both directions are named separately in the failure message — `missing`/`unreachable` (in the source, absent from the guarded set) and `phantom` (in the guarded set, absent from the source) — because the two are different defects and a single "sets differ" line invites fixing one and re-running.'
---
@@ -78,103 +78,6 @@ the answer is to create one, never to approximate it with a predicate over text.
accompanying prose a reviewer can falsify each round is worse than no guard, because — by this
record's own argument — a guard described as sound stops being re-examined.
**When the population is FILES, the authoritative source is the git index (#806).** The worked
examples above are an enum and a generated document, both unambiguously authoritative, and the
record was silent on the commonest population in this repo's own guards: files in a directory. Every
one of them answered with a filesystem walk, and #778 measured what that costs by getting the same
population wrong three times in one PR — a content filter that omitted `git fetch`, a non-recursive
`glob` that missed four nested files, and finally `rglob`, which enumerated `.husky/_/`: 17 husky
shims generated by `npm ci`, gitignored and untracked. That last one made the guard **red on every
developer checkout and green in CI**, whose `script-tests` job pip-installs but never runs `npm ci`.
The reason a walk keeps losing is not that each traversal was written carelessly; two of the three
were the obvious correction to the one before. It is that the disk answers a question about the
MACHINE and the guard is asking one about the REPO. Those coincide often enough for a walk to look
right and diverge exactly where generated output lands, which is to say wherever the tooling is
installed and nowhere else. The index is the repo's own statement of what it contains — the same
set of files every checkout receives from a clone, and excluding untracked files by construction
rather than by an exclusion list somebody maintains. It is not immutable and it is per-worktree; the
claim is not that it never changes, but that it changes only through a deliberate git operation — staging, a checkout, a
reset, a merge — whereas the disk changes whenever a build runs. Note what that buys over `.gitignore`-awareness: `.husky/_/`
happens to carry its own `.gitignore`, but a stray `foo.sh` in `.claude/hooks/` carries nothing, and
only the index knows it is not part of the repo.
The direction of the failure is worth naming, because it inverts the usual worry about a guard.
Under-enumeration hides a defect; this over-enumerated, and reddened correct trees. A guard that
fails everywhere except where it runs is not a cautious guard, it is a guard nobody reads — and it
had done that to the artifact whose entire thesis is population correctness.
**This did not become "replace every glob", and the boundary is the same one drawn above.** The
question per guard is whether it makes a completeness claim over TRACKED files. `_repo_copy` in
`scripts/tests/test_ci_release_path_scan_job.py` assembles a fixture and asserts nothing about
which files it found; it takes its file list from the index for HERMETICITY, which is a different
reason, and its docstring distinguishes the two.
`scripts/tests/test_ci_dropped_step_guard.py` has no filesystem population at all — it reads the
parsed workflow. Converting either would have been a change with no defect behind it, which spends
the credibility this rule needs when it does bite. The per-guard verdicts, including the two
no-change ones and the decisions corpus recorded as unexamined rather than cleared, are tabled in
`docs/guard-inventory.md`.
**The residue, named.** `git ls-files` reports INDEX entries, so a guard joins the population when
it is STAGED rather than when the file appears. Nothing local runs these checks — `.husky/pre-commit`
runs lint-staged, the decisions guard, the root-PNG check and `dotnet format`, and no husky hook runs
pytest — so the red arrives from `pr-checks.yml::script-tests` on the PR. A file deleted from the
working tree but not yet staged is still listed; `tracked_paths` asserts existence rather than
filtering it out, because a filter is what makes a missing member unrepresentable, which is this
record's first paragraph applied to its own implementation.
**Two traps specific to converting an existing guard, both of which this rule caught inside its own
implementation.** FIRST, follow the data to where members are actually ADMITTED, not just to where
the walk starts: a scrape that reads its caller files from the index and then admits the paths they
name on `Path.exists()` is half-derived and reads as fully derived. SECOND, a proof that a
derivation excludes untracked files must remove EVERY member in turn, not one. `derived_guard_files`
unions four contributors; a single victim is always drawn from whichever sorts first, so a mutant
putting only one contributor back on a filesystem walk passes. A sample cannot see the source it did
not draw from — this record's opening argument, one level down, inside the artifact written to
enforce it. Exhaustive removal is cheap — about a second at the population sizes here.
**Removal is only half the property, and the second half must not itself be machine-dependent.** A
source contributing ONLY untracked members adds and never removes, so a removal-based check has
nothing of its to take away: an `rglob` appending `.husky/_/` leaves the removal proof GREEN. Scope
that claim on both axes, because it is narrower than it first reads. A broader `rglob` that also
displaces tracked members DOES redden removal, so the blind spot is the append-only shape rather
than every filesystem walk; and the append-only shape is blind only where the walked directory
yields nothing — with the shims present its members are there to remove and removal reddens too. The
gap is therefore an append-only source that is empty ON THIS MACHINE, which is exactly the CI
checkout, and exactly where a guard going quiet is invisible.
The obvious complement — arrange an untracked file and require it not to enter — is a trap this
change fell into and backed out of twice, and the reason is worth more than the rule. Writing probe
files into the checkout under test means a probe in the `test_*.py` scope is a file pytest COLLECTS
mid-session, probe names collide across concurrent runs, `finally` does not survive a SIGKILL, and a
concurrent `git add -A` can stage one — defects in the test rather than in the thing tested.
Neutralising the shared derivation and requiring the population to go empty is clean but misses the
`.husky/_/` source on any machine where `.husky/_/` does not exist, which is the `script-tests`
checkout, since that job never runs `npm ci`. Both formulations reproduce the green-in-CI /
red-on-a-laptop asymmetry this record exists to abolish, inside the proof written to abolish it.
**So watch for the property that needs no arranged state: a directory LISTING issued while the
derivation runs.** Listing is the commonest way a derivation discovers a member the index does not
know about, and a walk issued during the derivation is caught on any machine — an `rglob` fails even
where the directory it walks is empty, because the evidence is the call rather than what it
returned. State its reach honestly, and state it once: what is observed is any
call that goes THROUGH ONE OF THE SPIES, whenever it happens — the check's docstring works through
the instances, and this record does not copy them, because the copy drifted from the original inside
a single commit. It is a regression guard against the shapes that arrive by accident, not a
boundary, and a guard sold as a boundary stops being re-examined.
**Three drafts of that one sentence were wrong, all in the same direction, and the third was wrong
in the copies after the original had been fixed** — which is the completeness rule biting the prose
that describes it. "Synchronously inside the call" was falsified by a thread finishing during the
drain; "while the patch is active" was falsified by a spy reference captured inside the window and
invoked after it, which still records. Each draft named the mechanism the author had in mind rather
than the one that decides, and each understated the coverage. Understating is the safe direction and
still worth correcting: a limit stated too narrowly invites someone to build the case it appears to
exclude. The durable lesson is the one this record already gives for populations — do not keep a
second copy of a statement that is still being corrected. Its complement is removal, which catches the shape that admits a HARDCODED
path without listing anything (`if (REPO_ROOT / "x").exists(): add` — the defect this change shipped
in its own first round). Neither alone is the property.
**What this record does not cover.** A population of *sites in code* — the places that dispatch on a
value — has no external enumerator. #403 is that case: `PlaybackOrder`'s values are enumerable, but
the defect was 5 of 6 dispatch sites, and nothing lists dispatch sites. That residue needs
@@ -5,8 +5,8 @@ status: active
since: '2026-08-13'
supersedes: none
superseded-by: none
rule: 'A guard is not considered tested because a test involving it passes. It ships with a MUTATION PROOF: remove or disarm THAT GUARD''S CLAUSE ALONE, and a NAMED test must go red. ONE NAMED EXCEPTION, with its limits, because the rule degenerates without it: where the guard IS a test (a checker enforcing a repo invariant, with no separate script behind it), disarming it makes it ABSENT rather than red, so the proof is the contrapositive — INTRODUCE THE DEFECT THE GUARD EXISTS TO CATCH into an isolated copy of the guarded artifact, and the named test must go red. That is a mutation of the guarded SYSTEM rather than of the assertion, and it is admissible ONLY for checker-guards and ONLY when the mutation was executed and witnessed. It is NOT a licence to grade an ordinary script-guard MUTATION for having a bad-input test: feeding a script an input its clause rejects is BEHAVIOUR-ONLY, which is what three rows were regraded for. A file-level grade under this exception covers the clause its cited case actually mutates, not every assertion that later lands in the same file. Three things this excludes, each of which has already shipped here as a green suite over a dead check. FIRST, a behavioural test — one that feeds the guard a good input and a bad input and checks it passes and fails — proves the guard REACTS, never that it is LOAD-BEARING; #685 had two guards on one condition where deleting either left the whole suite green while every behavioural test passed. SECOND, mutating the WHOLE FILE does not count (#510): a whole-file revert cannot show that a test reaches a particular clause, so the mutation must target the clause. THIRD, the guard being WIRED is not the guard RUNNING — #631''s suite was invoked by no CI job, #751''s step was dropped by the runner and the job reported success in 6s against a normal 14-17s, and #719''s new logic was never connected to stdin. Every guard that DERIVES A POPULATION also carries an ANTI-VACUITY assertion, because the characteristic failure of a completeness check is reporting that it proved everything while its population was empty; a guard with no population has nothing for such an assertion to be about, and stating it universally reads as coverage the unproven rows do not have. Mechanical enforcement is possible for the BOOKKEEPING and not for the JUDGEMENT, and the split is the decision: `docs/guard-inventory.md` lists every guard file with its Kind, its Proof class (`MUTATION`/`BEHAVIOUR-ONLY`/`NONE`) and a `file::function` ref, and `scripts/tests/test_guard_inventory.py` derives the guard population from the GIT INDEX and the call sites (#806), asserts SET EQUALITY against the rows, and resolves every claimed ref to a real `def`. So a new guard cannot ship unclassified and a renamed test cannot leave a row silently claiming coverage. Whether a row claiming `MUTATION` is telling the truth is no longer left to review: `testing.mutation-claims-are-executed` (#790) requires each such row to carry a DECLARED clause mutation that is applied to an isolated copy of the repository on every run, with the row''s own named test required to go red.'
signals: 'mutation proof · delete the guard alone see red · disarm the clause not the file · behaviour-only is not a proof · anti-vacuity assertion · guard wired is not guard running · a green job with no step output · `docs/guard-inventory.md` · set equality against the row set · proof ref resolves to a real def · the unproven majority is a moving figure — read it off the inventory · PROOF kind stops the regress · hook wiring is not hook existence · paths: `scripts/tests/test_guard_inventory.py`, `scripts/tests/test_ci_dropped_step_guard.py`, `scripts/ci-prove-ban-detects.sh` · issues: #775, #773, #751, #756, #719, #685, #631, #621, #510, #445'
rule: 'A guard is not considered tested because a test involving it passes. It ships with a MUTATION PROOF: remove or disarm THAT GUARD''S CLAUSE ALONE, and a NAMED test must go red. ONE NAMED EXCEPTION, with its limits, because the rule degenerates without it: where the guard IS a test (a checker enforcing a repo invariant, with no separate script behind it), disarming it makes it ABSENT rather than red, so the proof is the contrapositive — INTRODUCE THE DEFECT THE GUARD EXISTS TO CATCH into an isolated copy of the guarded artifact, and the named test must go red. That is a mutation of the guarded SYSTEM rather than of the assertion, and it is admissible ONLY for checker-guards and ONLY when the mutation was executed and witnessed. It is NOT a licence to grade an ordinary script-guard MUTATION for having a bad-input test: feeding a script an input its clause rejects is BEHAVIOUR-ONLY, which is what three rows were regraded for. A file-level grade under this exception covers the clause its cited case actually mutates, not every assertion that later lands in the same file; clause-level grading is tracked in #790. Three things this excludes, each of which has already shipped here as a green suite over a dead check. FIRST, a behavioural test — one that feeds the guard a good input and a bad input and checks it passes and fails — proves the guard REACTS, never that it is LOAD-BEARING; #685 had two guards on one condition where deleting either left the whole suite green while every behavioural test passed. SECOND, mutating the WHOLE FILE does not count (#510): a whole-file revert cannot show that a test reaches a particular clause, so the mutation must target the clause. THIRD, the guard being WIRED is not the guard RUNNING — #631''s suite was invoked by no CI job, #751''s step was dropped by the runner and the job reported success in 6s against a normal 14-17s, and #719''s new logic was never connected to stdin. Every guard also carries an ANTI-VACUITY assertion, because the characteristic failure of a completeness check is reporting that it proved everything while its population was empty. Mechanical enforcement is possible for the BOOKKEEPING and not for the JUDGEMENT, and the split is the decision: `docs/guard-inventory.md` lists every guard file with its Kind, its Proof class (`MUTATION`/`BEHAVIOUR-ONLY`/`NONE`) and a `file::function` ref, and `scripts/tests/test_guard_inventory.py` derives the guard population from the filesystem and the call sites, asserts SET EQUALITY against the rows, and resolves every claimed ref to a real `def`. So a new guard cannot ship unclassified and a renamed test cannot leave a row silently claiming coverage. What stays with review, and is stated rather than papered over: nothing checks that a row claiming `MUTATION` is telling the truth.'
signals: 'mutation proof · delete the guard alone see red · disarm the clause not the file · behaviour-only is not a proof · anti-vacuity assertion · guard wired is not guard running · a green job with no step output · `docs/guard-inventory.md` · set equality against the row set · proof ref resolves to a real def · 21 of 32 guards unproven · PROOF kind stops the regress · hook wiring is not hook existence · paths: `scripts/tests/test_guard_inventory.py`, `scripts/tests/test_ci_dropped_step_guard.py`, `scripts/ci-prove-ban-detects.sh` · issues: #775, #773, #751, #756, #719, #685, #631, #621, #510, #445'
mechanics: 'Proof classes are a closed vocabulary enforced by the inventory test; a `TOOLING` row may not claim a proof. `scripts/ci-prove-ban-detects.sh` is the one guard that runs its own mutation at CI time rather than in pytest, because the thing it proves — that the ban test is not deselected — is disarmable from inside pytest configuration.'
---
@@ -35,8 +35,8 @@ and a bad input establishes that the guard's logic responds to its argument. It
whether that logic is *connected* — to the runner, to the caller, to the exit code anyone reads.
#719 and #631 would both have passed such a test on the day they shipped dead. This is why
`docs/guard-inventory.md` grades `BEHAVIOUR-ONLY` separately from `MUTATION` rather than counting
them together: they answer different questions, and adding them up is how the unproven majority gets
reported as covered. The current split is in the inventory's own summary line, which is derived-checked.
them together: they answer different questions, and adding them up is how 19 unproven guards get
reported as covered.
**The model, and what makes it the model.**
`test_ci_dropped_step_guard.py::test_dropping_ANY_single_step_FAILS_the_guard` removes each marked
@@ -51,13 +51,10 @@ expected step(s) executed* and exiting 0. Four kinds, none substituting for anot
now enforced: the inventory's population is derived, compared for set equality in both directions,
and every `Proof ref` is resolved to a real `def`. A new guard cannot be added without being
classified; a renamed test cannot leave a row claiming a proof that evaporated. That closes the two
ways this decays silently. The judgement half — *is this row's `MUTATION` claim true?*was left with
review here, and is mechanised by `testing.mutation-claims-are-executed` (#790). A generic mutation
runner for shell hooks was considered and rejected at this point: it would have to know which clause
of a 90-line hook is the guard, and a runner that guesses would manufacture exactly the
confident-but-empty coverage this record exists to prevent. That objection stands and is what the
later harness is built around — the clause is DECLARED per row rather than inferred, which is the
only form of runner this record's argument permits.
ways this decays silently. The judgement half — *is this row's `MUTATION` claim true?*is not
mechanical and was not faked. A generic mutation runner for shell hooks was considered and rejected:
it would have to know which clause of a 90-line hook is the guard, and a runner that guesses would
manufacture exactly the confident-but-empty coverage this record exists to prevent.
**What "disarm the clause" means when the guard IS a test, because the rule degenerates otherwise.**
For a guard implemented as a shell script with a separate test, disarming is literal: delete the
@@ -77,10 +74,7 @@ guard. Recorded because the distinction will come up again.
Where that review was straightforwardly right: neutering `pin_population_faults` wholesale (20 of 25
red) is coarser than disarming one clause at a time, and coarse enough that a single surviving
clause would not be noticed. #790 replaced it with a single-clause mutation — disarming the
against-the-registry comparison alone reddens the proof, because a job that loses its `container:`
block leaves the two derived sets equal and only that comparison notices. Which guards admit
clause-level proof and which do not is now measured per row rather than assumed.
clause would not be noticed. Read it as a floor, not as per-clause coverage.
**Applied to itself, which is the only honest test of a rule like this — and it failed twice before
it passed.** The population guard's first draft compared two derived sets that shrank together, so
@@ -1,84 +0,0 @@
---
key: testing.mutation-claims-are-executed
title: '2026-08-22 — a MUTATION grade is EXECUTED every run, from a declared clause, or it is not that grade (#790)'
status: active
since: '2026-08-22'
supersedes: none
superseded-by: none
rule: 'A `MUTATION` row in `docs/guard-inventory.md` is not a statement that someone once witnessed a red. It carries a DECLARED clause mutation in `scripts/tests/mutation_manifest.py`, and `scripts/tests/test_mutation_harness.py` applies that mutation to an isolated copy of the repository on every run and requires the row''s OWN named test to go red. The manifest and the MUTATION rows are compared for SET EQUALITY in both directions, so a row cannot claim the grade without a mutation and a mutation cannot outlive the grade it justifies. EXIT STATUS IS NOT THE VERDICT: each entry also declares the DIAGNOSTIC its red must carry, matched against pytest''s exception output alone, because pytest reports a crashing test exactly as it reports a detecting one and a red for an unrelated reason is evidence about nothing. WHERE THE GUARD IS ITSELF A TEST, `target` may differ from `guard` and the exact-once check applies to the declared TARGET. Two shapes are admissible and the choice is not free. Where the guard''s assertion IS the check — a completeness comparison against a Markdown inventory — the mutation goes into the guarded ARTIFACT, per `testing.guard-ships-with-mutation-proof`''s checker-guard exception, because mutating such a checker''s own POPULATION demonstrates a false POSITIVE while proving nothing about the detection the row claims. Where the guard is a test module wrapping a separately mutable DETECTOR or helper, the clause may be in that detector, since disarming it is a real clause disarm and the module''s own assertion is what notices. THE MUTATION IS DECLARED, NEVER INFERRED: a harness that guessed which clause of a 90-line hook is the guard would manufacture the confident-but-empty coverage this exists to prevent, which is why `testing.guard-ships-with-mutation-proof` rejected a generic runner. Where a proof test already names its clause in source, the manifest reuses THAT string, so a retarget in either place is caught by the other. COARSENESS IS RECORDED, NOT HIDDEN: each entry is graded `CLAUSE` or `DETECTOR`, and a `DETECTOR` entry — one whose detector accumulates faults from independent arms, so disarming any single arm leaves its proof test green — must CARRY the finer mutation that survived, which is re-run every time and required to keep surviving. Guards that are not graded `MUTATION` each carry a STATED reason in that same manifest, keyed on the guard and compared for SET EQUALITY against the inventory''s `GUARD` rows in both directions — so a new guard cannot arrive without someone writing what a proof would need, and a reason cannot outlive the row it is about. Keying the reason on the row''s GRADE instead is tautological (a new guard inherits one and nobody looks at it) and a pinned COUNT moves only on net change; both were tried and are rejected. The sandbox is a real git repository built from `git ls-files` with working-tree content, never a filesystem walk.'
signals: 'mutation harness · declared clause not inferred · MUTATION row is executed · set equality manifest vs inventory · CLAUSE vs DETECTOR granularity · surviving finer mutation is re-run · pytest exit code 1 is the only red that counts · every other exit status is rejected · positive control before any mutation · sandbox from git ls-files · paths: `scripts/tests/test_mutation_harness.py`, `scripts/tests/mutation_manifest.py`, `scripts/tests/mutation_harness_lib.py`, `docs/guard-inventory.md` · issues: #790, #775, #774, #778, #806'
mechanics: 'The harness builds ONE sandbox per session — tracked files only, from `git ls-files -s`, in an environment with every `GIT_*` variable stripped so an exported `GIT_DIR`/`GIT_COMMON_DIR`/`GIT_CONFIG_*` cannot point its `init`/`add`/`commit` at the real repository — and resets it between mutations with `git reset --hard` TO A BASELINE COMMIT RECORDED OUTSIDE THE REPOSITORY, plus `git clean -qffdx`. Both halves matter: bare `git reset --hard` resets to whatever HEAD is, so a proof test that COMMITS inside the sandbox moves HEAD onto a commit carrying the mutant and every later reset restores it faithfully; and a baseline held as a REF inside the sandbox is one more thing a proof can move. `core.hooksPath`, `commit.gpgsign` and `core.worktree` are pinned on every git invocation for the same reason. The `-ff` removes a nested repository a proof may have left. It runs the full set of named proof tests UNMUTATED first, inside the fixture rather than as a separate test so ordering is a dependency and not a convention. Only pytest exit code 1 counts as red; every other status is rejected. That is what closes the two ways a proof ref goes stale, and they are measured rather than assumed: with an explicit `file.py::function` node id a missing file and a missing function both exit 4, while 5 needs a collection that succeeded and selected nothing. The sandbox skips the `ErsatzTV-macOS` gitlink and recreates the `.claude/skills/jellyfin` symlink as a symlink. Cost: ~14s, against a ~4min `script-tests` suite.'
---
**What #775 left open, in its own words: "nothing checks that a row claiming `MUTATION` is telling
the truth."** It said so deliberately — the bookkeeping half (population, set equality, proof refs
resolving to a real `def`) was mechanised, and the judgement half was left with review. Both cold
reviews of #774/#775 came back to the same place: the witnessing had happened once, by hand, and
**hand-run evidence decays the moment someone edits the guard.** Two false `MUTATION` grades were
caught by *reading* in the first draft, and a third round would likely have found more.
**Why a generic runner was rejected, and what changed.** #775 rejected a mutation runner for shell
hooks on the grounds that it "would have to know which clause of a 90-line hook is the guard". That
objection is correct and is not answered by better inference — it is answered by not inferring. The
clause is *declared*, one entry per row, and for most rows the declaration already existed: the
proof tests name their own clauses in source (`= "efbbbf" ]; then`, `UNSET_CLAUSE`, `if [ "$RC"
-eq 0 ]; then`). Reusing those strings rather than inventing parallel ones is what makes the two
halves catch each other's drift.
**What a green harness proves, and what it does not.** It proves the proof ref names a test that
still exists and still collects; that the declared clause still occurs, exactly once, in the entry's
declared `target`, which is not always the guard's own file; and — via the positive control — that every named proof test is GREEN on the
unmutated sandbox, so "the mutation was noticed" cannot be confused with "the test was already red".
The proofs that carry a disarm of their own run it during that control; the two that are plain
production set-equality checks have none, and the harness supplies theirs. It does **not** prove the declared clause is the only thing the guard hangs on.
For three entries (measured 2026-08-22: the BOM guard, `build_decisions_catalog.py`
and `prove-fix.sh`) the redness arrives through the proof test's own "this clause has moved, RETARGET
it" assertion rather than through changed behaviour. That is the intended reading rather than a hole:
those tests perform their behavioural disarm themselves on every green run; what they could not do
was notice their own clause reference going stale. Every other entry reddens behaviourally, and each
one's declared diagnostic says which.
**The `DETECTOR` grade is the honest half, and it exists because measuring found a case.** #775's
record concedes that neutering `pin_population_faults` wholesale is "coarser than disarming one
clause at a time — coarse enough that a single surviving clause would not be noticed". Running the
finer mutations settled which guards actually admit clause-level proof: all but one do,
including
`pin_population_faults`, where disarming the against-the-registry comparison alone reddens the proof
because a job that loses its `container:` block leaves the two derived sets equal. One does not.
`instrumentation_faults` accumulates from four independent arms and a stripped hook trips three of
them at once, so disarming any single arm leaves the proof test green — measured, by running
`if not _SOURCES_SINK.search(text):``if False:` and watching it pass. That surviving mutation is
carried as data and re-run on every suite: if it ever starts reddening, the guard has become
clause-provable and the entry must be regraded. A grade that cannot decay quietly is the point.
**Three shapes that were tried and rejected, because each looks like verification and is not.**
Emptying a checker's own population reddens its proof with an `IndexError` — a crash, not a
detection, and any breakage of the derivation satisfies it equally. Shrinking the guard-inventory
checker's hook glob reddens its proof by making every real row report as PHANTOM: a false positive,
which says nothing about the missing-row detection the row claims. And planting a phantom row in
`docs/remote-state-inventory.md` reddens a proof test that builds a phantom of its own and asserts
the difference is exactly one entry — so the declared diagnostic appears through fixture
contamination, in the opposite direction from the one the row claims. All three are replaced by
artifact-level mutations that reach the guard's production assertion — a deleted inventory row, a
renamed one — and `expect` is what makes the difference visible instead of arguable.
**Stating the other guards, and the two cheaper shapes that do not work.** Keying the reason on the
row's *grade* is tautological: a new guard graded `NONE` inherits a sentence automatically and nobody
ever looks at that particular guard. Pinning the *count* of undeclared guards is no better, because
it moves only on net change — one guard arriving as another is promoted leaves it unchanged. What
ships is a per-guard mapping compared for set equality against the inventory's `GUARD` rows. That is
a hand-maintained table, which is normally the duplication family to avoid (#774's wrong summary
counts, #788's verdict vocabulary) — but bidirectional set equality is what makes
`docs/guard-inventory.md` itself safe, and it is what turns "state the ones you cannot declare" into
something a new guard cannot slip past. Set equality ALONE is not sufficient and the gap is
demonstrable: every comparison here reduces rows through a set or a dict, so a DUPLICATED row is
invisible to all of them and a table contradicting itself reports full coverage. Duplicate rejection
is a separate, explicit check, and copying the set-equality design without it recreates the hole.
**Two words that are not interchangeable.** A `NONE` row means the row nominates no proof; it does
not mean the guard is untested. `scripts/ci-prove-ban-detects.sh` is graded `NONE` and is driven end
to end by `test_ci_release_path_scan_job.py`, and eight hooks are driven through their deciding path
by `test_hook_fire_log.py` — whose matrix asserts instrumentation TRANSPARENCY, that the wrapped and
unwrapped runs agree, never that the decision is right or that a particular clause produced it. A
statement that reads "no test drives this" over those guards sends the next session's proof work in
the wrong direction, so each entry says which of the two is actually missing.
-38
View File
@@ -445,44 +445,6 @@ machine lints differently, or not at all. One of the two must move — either th
committed ruff config and enforces it, or the global instruction stops claiming this repo enforces
something it does not.
**Resolved 2026-08-21 (#780): the repo moved.** `ruff.toml` is committed at the root and the
`script-tests` job runs `ruff check` + `ruff format --check` under a pinned `ruff==0.12.11` over an
explicit population from `git ls-files` — not `ruff check .`, which an `exclude` in the right config
scope silently empties into a green run. The pre-fix state, reproducible rather than
asserted — against `706674272`, the base this landed on, with the committed config dropped in:
```
mkdir -p ~/scratch/m780 && git archive 706674272 | tar -x -C ~/scratch/m780
git show 01f7a89e8:ruff.toml > ~/scratch/m780/ruff.toml # a sha: the file is not on main pre-merge
cd ~/scratch/m780 && ruff check . ; ruff format --check . # ruff 0.12.11
# -> Found 74 errors. / 20 files would be reformatted, 13 files already formatted
```
(`;` not `&&``ruff check` exits 1, which would swallow the second command. Not `/tmp`: macOS purges
it. Redirect the config into place *before* running anything: an empty `ruff.toml` is valid, so a
failed `git show` leaves ruff silently using its own defaults and printing a different number.)
The row above measured **47** eight days earlier against the operator's global config; the tree grew
and the configs differ, so the two numbers are not comparable and neither supersedes the other. Two
of the 74 are `RUF100` on suppressions that were already in the tree before this change — they exist
in this count only because the committed config enables that rule.
Of the 74, **57 were fixed in code** (most of them by the `ruff format` pass itself, which splits the
40 semicolon statements) and **17 carry a per-site `# noqa` with its reason inline**: 8 `S105` on stub
credentials handed to the real hooks by `scripts/tests`, 9 `E501` on one-line JSON and shell fixtures.
The `S105`s are deliberately per-site rather than a directory exemption, so a real credential pasted
into a fixture later still reddens the gate. Only `S101` is exempted directory-wide for `scripts/tests/**`,
because a test suite asserts.
`RUF100` is selected, which is what keeps that split honest: a `# noqa` that suppresses nothing still
reads as a suppression, and it is invisible without this rule. Three were live when it was switched
on: one on a site that had already been fixed in code, plus the two counted above — one whose rule had
stopped firing, one for a rule this config never enables.
`pyright` stayed ungated: its only findings are the `etv_client` imports in the row above, and gating
it would put a node toolchain on the git-only `small` lane to find nothing. Rationale, the exemption
list and the measured exclude matrix: `ci.python-lint-ruff-config-committed`.
### 5.3 Configured vs actually invoked
Measured over the session transcript corpus (811 files under
+39 -241
View File
@@ -1,16 +1,9 @@
# Guard inventory (ersatztv#774 / #775)
Every executable guard **file** in this repo, what it blocks, and whether it ships a proof it can go
red. `scripts/tests/test_guard_inventory.py` derives the population from the **git index** and the
red. `scripts/tests/test_guard_inventory.py` derives the population from the filesystem and the
workflow/hook call sites and asserts **set equality** against the `Guard` column, so a new guard
cannot be added without acquiring a row here, and a row cannot name a proof that does not exist.
The index rather than a filesystem walk since ersatztv#806 — the practical consequence is that a new
guard joins the population when it is **staged**, not when the file appears. Nothing local runs
these checks at all: `.husky/pre-commit` runs lint-staged, the decisions guard, the root-PNG check
and `dotnet format`, and `grep -rn pytest .husky/` returns nothing. The runner is
`pr-checks.yml::script-tests`, `on: pull_request`, so the red arrives in CI — plus
`docker-build.yml:723` on the release path, which re-runs two of these files
(`test_ci_dropped_step_guard.py` and `test_ci_release_path_scan_job.py`) as a `needs:` of `build`.
**Read `docs/decisions/records/testing/guard-derives-population-from-source.md` and
`…/guard-ships-with-mutation-proof.md` before editing a guard or adding a row.**
@@ -47,43 +40,25 @@ exists because arguments of that shape have been wrong here six times.
## Scope limit, stated rather than implied
This inventory covers guard **files**, discovered by reading `.claude/hooks/*.sh`, `.husky/*` and
`scripts/tests/test_*.py` **out of the git index**, plus every `scripts/…` path referenced by a
workflow or a hook. The classes below are outside that population. They are listed because the first
version of this section named only the first one, and cold review found that the very guards this
inventory shipped with were sitting in the gap:
This inventory covers guard **files**, discovered by globbing `.claude/hooks/*.sh`, `.husky/*` and
`scripts/tests/test_*.py`, plus every `scripts/…` path referenced by a workflow or a hook. Six
classes are outside that population. They are listed because the first version of this section named
only the first one, and cold review found that the very guards this inventory shipped with were
sitting in the gap:
1. **Guards inline in workflow YAML** — most importantly `pr-checks.yml:ci-image-pin`. "Which jobs
are guards" needs a judgement call per job the filesystem cannot supply. Two were audited under
#774 and one fixed; extending the population is tracked in #786.
2. **C# and TypeScript guards**`ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`,
`web/src/api/pageSizeCallSites.guard.test.ts` and `web/src/api/completeRequest.guard.test.ts`
are all structural guards and none has a row. Note what that costs: this list is a HAND-WRITTEN
mirror of a population nothing derives, so it goes stale silently and CI stays green — #807
added the third entry, and only review caught that the second had become the only one named.
2. **C# and TypeScript guards**`ErsatzTV.Mcp.Tests/ToolCatalogTests.cs` and
`web/src/api/pageSizeCallSites.guard.test.ts` are both structural guards and neither has a row.
3. **Mentions counted as call sites.** The `scripts/…` scrape matches any occurrence, including
inside a comment or an `::error::` string. `scripts/update-openapi.sh` is named in a
`pr-checks.yml` error message, so removing the step that runs it would leave its row intact.
4. **Nested and non-lowercase paths** beyond `scripts/tests/` — a guard under
`scripts/scripted-schedules/`, or with an uppercase name, is invisible to the scrape.
5. **Non-`.sh` hooks** — the hook pattern is `*.sh` only. (`.yaml` workflows are no longer in this
gap: the caller scan matches `*.yml` and `*.yaml` since #806, because Gitea accepts both.)
5. **`.yaml` workflows and non-`.sh` hooks** — the globs are `*.yml` and `*.sh` only.
6. **Transitive calls** — a script invoked only by another script, rather than by a workflow or
hook, is not discovered.
7. **Non-`test_` modules under `scripts/tests/`** — the pattern is `test_*.py`, so `conftest.py`,
`mutation_harness_lib.py`, `mutation_manifest.py` and `tracked_files.py` are outside the
population and hold no rows. They are not guards (they assert nothing on their own), but the
middle two ARE what `test_mutation_harness.py` is made of, so gutting either would take that
guard with it. What catches that is the guard's own row: its declared mutation targets
`mutation_harness_lib.py`, and its proof test refuses to run if the clause it names has moved.
`tracked_files.py` has no such backstop — it is load-bearing for every module that imports it
(#806), and an edit to it is covered only by those modules' own proofs. Recorded rather than
force-fitted: a row for a library would need a `Kind` the vocabulary does not have.
8. **Nested workflow directories** — the workflow scope is direct children of `.gitea/workflows`, so
a tracked `.gitea/workflows/nested/x.yml` is invisible to the caller scan and to
`test_ci_image_pin_population.py`. Left as scope rather than widened: whether Gitea executes
nested workflow files was not verified here, and widening on an unverified premise risks a
permanent red on a correct tree, which is how a correct guard gets deleted.
Hook **wiring** is checked (`test_every_hook_file_is_actually_WIRED` reads `.claude/settings.json`
and the husky hooks with full-line comments stripped), so a hook file whose registration is deleted
@@ -94,71 +69,13 @@ through a wrapper or a constructed path reads as unwired. It catches deletion, w
case; it does not catch deliberate disablement. The check does not extend to the `scripts/` half at
all.
## File populations and where they come from (ersatztv#806)
Every guard here whose members are FILES derives them from the **git index**, never a filesystem
walk. The disk is not an authoritative source: it reports build output and editor droppings and
differs per machine, so a guard derived from it asserts a different population in CI than on the
laptop of the person it is meant to stop. `scripts/tests/tracked_files.py` is the single derivation
and carries the full rationale; `scripts/tests/test_guard_populations_derive_from_git.py` proves it,
in both directions: removing EVERY member of each registered derivation from the index one at a time
and requiring it to disappear while still on disk, and watching for a directory LISTING issued while
the derivation runs (reading files stays allowed). Removal alone is blind to a source that
contributes only untracked members — an `rglob` reaching `.husky/_/` adds and never removes — and
any check phrased as "an untracked file must not enter" is itself machine-dependent, because the
untracked file has to exist. Watching for the call needs no arranged state.
That second check is a regression guard against the accidental shapes, not a boundary: what is
observed is any call that goes **through one of the spies**, whenever it happens — the spy records
into a list that outlives the patch, so a reference captured during the window and invoked after it
still counts. Whether the call goes through a spy is what decides, not when. The instance list — what
never reaches a spy at all — lives in the check's own docstring and is deliberately not restated
here, because a second copy of it drifted from the first within one commit.
One deliberate exception to "from the index" sits in the same file: the registration check lists
`scripts/tests/test_*.py` from disk on purpose, because it is a superset check over what pytest
collects — an untracked stray there makes it MORE demanding, never blind, whereas using the index
would let an unstaged new guard escape registration.
Its own limit, stated because a check described as complete stops being re-examined: it finds
derivations by PARSING each `test_*.py` for an import of the shared helper, so a module that derives
a file population some other way is invisible to it, and no mechanical check can close that (#774
reached the same conclusion about detecting filter-shaped guards by token).
The audit #806 asked for, recorded whichever way it came out, because "we looked and left it" and
"we never looked" are indistinguishable a year later:
| Guard | Population | Completeness claim over tracked files? | Outcome |
| --- | --- | --- | --- |
| `test_guard_inventory.py` | `.claude/hooks/*.sh`, `.husky/*`, `scripts/tests/test_*.py`, workflow/hook callers | **yes** — set equality against this table | converted to the index; `.husky/_/` had been excluded only because `_` is a directory, so the obvious "make it recursive" edit would have reintroduced #778's defect here |
| `test_hook_fire_log.py` | `.claude/hooks/*.sh` | **yes** — every hook must be instrumented | converted; an untracked scratch `.sh` used to demand instrumentation and redden the suite on that checkout alone |
| `test_ci_image_pin_population.py` | `.gitea/workflows/*.yml` + `*.yaml` | **yes** — "docker-build is the ONLY workflow pinning the toolchain image" | converted, and `*.yaml` added: Gitea accepts both spellings, so a `.yaml` workflow was structurally invisible while the test read as covering all of them |
| `test_pr_changed_files.py` | `.gitea/workflows/*.y*ml` | **yes** — "no OTHER workflow writes the review-verdict status" | converted. Not on #806's list and found by cold review on the pushed head: an untracked `.yaml` dropped in `.gitea/workflows/` reddened two guards while absent from the index — the issue's list of files to assess was a starting point, not the population |
| `web/src/api/pageSizeCallSites.guard.test.ts` | `web/src/**/*.{ts,tsx,mts,cts}` via `import.meta.glob` | **yes** — an unregistered discovered site fails | DEFERRED to #819, assessed not skipped. The glob is a documented workaround: `@types/node` is deliberately out of `tsconfig.app.json`, and wiring it in was tried and reverted (it leaked Node's `setTimeout` into the app project and broke three unrelated tests), so there is no `node:child_process` to reach the index from. Over-enumerates, so it fails loudly rather than going blind |
| `test_ci_release_path_scan_job.py` | `.gitea/workflows/*.y*ml` + `scripts/**` | **no** — a fixture assembling a tmp harness, asserted about behaviour not membership | takes its file LIST from the index anyway, for hermeticity not completeness: `shutil.copytree` copied whatever was on disk, including untracked files and `scripts/__pycache__`, into a tree whose behaviour the probes then measure. Content still comes from the working tree. The copy is not a git repo, so the two files this step RUNS may not use the helper — see the fixture docstring |
| `test_ci_dropped_step_guard.py` | the parsed workflow document | **no filesystem population at all** | unchanged; its residual is `MARKED_JOBS`, a SCOPE mirror of the required contexts on `main`, which #806 does not close |
`test_remote_state_inventory.py` fixed its own population under #778 and kept a private copy of the
derivation; #806 folded it onto the shared one, so that module is covered by the proof above like
the rest. Across the whole change, every module that derived a file population its own way now goes
through `tracked_files.py` — one implementation of the rule instead of one per module. The
registered derivations are listed in `DERIVATIONS` in
`scripts/tests/test_guard_populations_derive_from_git.py`; this page deliberately keeps no count
of them.
**Still on filesystem walks, deliberately out of scope:** the decisions corpus
(`scripts/decisions_lib.py`'s `active_files()`, and the suites over it). Its members are `docs/`
Markdown with no generated-file pressure and a different lifecycle, and folding it in here would
have been the reflex this milestone argues against — a change with no defect behind it. It is
recorded as unexamined rather than as cleared.
## Inventory
| Guard | Blocks | Kind | Proof | Proof ref |
| --- | --- | --- | --- | --- |
| `.claude/hooks/decisions-guard.sh` | a commit | GUARD | NONE | — |
| `.claude/hooks/design-sync-reminder.sh` | the first Stop after a UI change (one-shot, then allows) | GUARD | NONE | — |
| `.claude/hooks/posttooluse-worktree-marker.sh` | nothing (writes the marker the worktree guard reads) | GUARD | MUTATION | `test_worktree_ownership_guard.py::test_MUTATION_a_marker_hook_that_stops_WRITING_makes_the_guard_go_quiet` |
| `.claude/hooks/posttooluse-worktree-marker.sh` | nothing (writes the marker the worktree guard reads) | GUARD | NONE | |
| `.claude/hooks/prepush-clean-worktree-check.sh` | a push with uncommitted changes in the pushed set | GUARD | NONE | — |
| `.claude/hooks/prepush-donewhen.sh` | a direct push to `main` with unticked Done-when boxes | GUARD | NONE | — |
| `.claude/hooks/prepush-rebase-check.sh` | a push from a branch behind `origin/main` | GUARD | BEHAVIOUR-ONLY | `test_prepush_rebase_check_tag_exemption.py::test_zero_ref_lines_does_not_exempt` |
@@ -168,12 +85,11 @@ recorded as unexamined rather than as cleared.
| `.claude/hooks/pretooluse-bom-guard.sh` | a commit/push carrying a BOM in a touched `.cs` | GUARD | MUTATION | `test_bom_guard_detection.py::test_DISARMING_the_BOM_comparison_stops_detection` |
| `.claude/hooks/pretooluse-merge-consent.sh` | a PR merge without derived consent | GUARD | BEHAVIOUR-ONLY | `test_merge_consent_exemption.py::test_protected_path_on_a_LATER_page_is_still_seen` |
| `.claude/hooks/pretooluse-nav-guard.sh` | a browser navigate to a streaming URL | GUARD | NONE | — |
| `.claude/hooks/pretooluse-worktree-guard.sh` | a commit/merge in a foreign worktree | GUARD | MUTATION | `test_worktree_ownership_guard.py::test_MUTATION_disarming_the_guards_MARKER_READ_stops_the_deny` |
| `.claude/hooks/pretooluse-worktree-guard.sh` | a commit/merge in a foreign worktree | GUARD | NONE | |
| `.husky/commit-msg` | a commit with no `Co-Authored-By` trailer | GUARD | NONE | — |
| `.husky/pre-commit` | a commit failing lint-staged, decisions, root-PNG or format | GUARD | NONE | — |
| `.husky/pre-push` | a push failing any pre-push hook or the SPA gate | GUARD | MUTATION | `test_prepush_unsets_git_env.py::test_MUTATION_DELETING_the_unset_lets_drift_through_silently` |
| `scripts/build_decisions_catalog.py` | the `decisions-guard` job, on a stale catalog | GUARD | MUTATION | `test_build_catalog_check_path.py::test_MUTATION_disarming_the_stale_comparison_stops_detection` |
| `scripts/check-doc-narrative.py` | nothing, by design (advisory `::warning::` only, exits 0 on every path (the error/degradation arms are defensive and unproven — see the record) — `docs.no-session-narrative` says a string predicate over prose may not be load-bearing) | TOOLING | NONE | — |
| `.husky/pre-push` | a push failing any pre-push hook or the SPA gate | GUARD | NONE | |
| `scripts/build_decisions_catalog.py` | the `decisions-guard` job, on a stale catalog | GUARD | NONE | |
| `scripts/check-kickoff-guard.sh` | the `decisions-guard` job, on a revived #237 reference | GUARD | NONE | — |
| `scripts/check-review-verdict.sh` | the merge-consent hook's verdict classification | GUARD | BEHAVIOUR-ONLY | `test_check_review_verdict.py::test_falseopen_token_must_be_a_whole_word` |
| `scripts/ci-detect-already-validated.sh` | nothing directly (feeds the skip gate) | GUARD | NONE | — |
@@ -181,7 +97,6 @@ recorded as unexamined rather than as cleared.
| `scripts/ci-peak-anon.sh` | nothing (samples container memory) | TOOLING | NONE | — |
| `scripts/ci-prove-ban-detects.sh` | the release path, if the delimiter ban is disarmed | GUARD | NONE | — |
| `scripts/ci-step-ran.sh` | the two required contexts, on a dropped step | GUARD | MUTATION | `test_ci_dropped_step_guard.py::test_dropping_ANY_single_step_FAILS_the_guard` |
| `scripts/ci-toolchain-image-resolves.sh` | the `toolchain-preflight` job, when the pinned CI toolchain image has been deleted from the registry | GUARD | MUTATION | `test_ci_toolchain_image_resolves.py::test_MUTATION_a_deleted_tag_is_reported_as_a_failure` |
| `scripts/decisions_validate.py` | the `decisions-guard` job, on a lifecycle fault | GUARD | MUTATION | `test_decisions_validate.py::test_main_actually_CALLS_the_wing_scan` |
| `scripts/e2e-functional.sh` | the Functional E2E job, on a failed HTTP contract assertion | GUARD | NONE | — |
| `scripts/e2e-local.sh` | nothing (boots a local instance) | TOOLING | NONE | — |
@@ -194,55 +109,27 @@ recorded as unexamined rather than as cleared.
| `scripts/update-openapi.sh` | nothing (regenerates the spec) | TOOLING | NONE | — |
| `scripts/tests/test_bom_guard_detection.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_build_catalog.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_build_catalog_check_path.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_check_review_verdict.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_check_doc_narrative.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_ci_dropped_step_guard.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_ci_image_pin_population.py` | the `script-tests` job, when a container job loses its pin | GUARD | MUTATION | `test_ci_image_pin_population.py::test_a_single_job_losing_its_pin_is_DETECTED` |
| `scripts/tests/test_ci_release_path_scan_job.py` | the `script-tests` job, on a weakened release-path scan job | GUARD | NONE | — |
| `scripts/tests/test_ci_toolchain_image_resolves.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_decisions_lib.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_decisions_validate.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_guard_inventory.py` | the `script-tests` job, on an unclassified guard or a stale proof ref | GUARD | MUTATION | `test_guard_inventory.py::test_the_inventory_covers_exactly_the_guards_that_exist` |
| `scripts/tests/test_guard_populations_derive_from_git.py` | the `script-tests` job, on a guard whose file population admits a file git does not track | GUARD | MUTATION | `test_guard_populations_derive_from_git.py::test_no_derivation_admits_an_untracked_file` |
| `scripts/tests/test_hook_fire_log.py` | the `script-tests` job, on a hook that stops reporting that it fired, or whose reporting changes what the harness sees | GUARD | MUTATION | `test_hook_fire_log.py::test_a_hook_that_LOSES_its_instrumentation_is_DETECTED` |
| `scripts/tests/test_jq_preflight.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_merge_consent_base_change.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_merge_consent_exemption.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_merge_consent_required_check.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_migration_equivalence.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_mutation_harness.py` | the `script-tests` job, when a `MUTATION` row's declared clause no longer reddens the test the row names | GUARD | MUTATION | `test_mutation_harness.py::test_MUTATION_disarming_the_DIAGNOSTIC_gate_accepts_a_red_for_the_wrong_reason` |
| `scripts/tests/test_post_review_verdict.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_prepush_unsets_git_env.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_pr_changed_files.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_prepush_rebase_check_tag_exemption.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_optional_request_members.py` | the `script-tests` job, on an OpenAPI request schema that can silently drop a member with no stated disposition | GUARD | MUTATION | `test_optional_request_members.py::test_every_droppable_request_schema_has_a_stated_disposition` |
| `scripts/tests/test_prove_fix.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_remote_state_inventory.py` | the `script-tests` job, on an executable that talks to a remote service with no row in `docs/remote-state-inventory.md` | GUARD | MUTATION | `test_remote_state_inventory.py::test_every_in_scope_file_has_a_row_and_every_row_names_a_real_file` |
| `scripts/tests/test_worktree_ownership_guard.py` | the `script-tests` job | PROOF | NONE | — |
## The `MUTATION` column is executed, not asserted
Every row graded `MUTATION` carries a DECLARED clause mutation in `scripts/tests/mutation_manifest.py`.
`scripts/tests/test_mutation_harness.py` applies each one to an isolated copy of this repository and
requires that row's own named test to go red; the manifest and the `MUTATION` rows are compared for
set equality in both directions, so the grade and the mutation cannot drift apart. Adding a row
graded `MUTATION` without declaring its clause fails the suite. Full contract and its limits:
`docs/decisions/records/testing/mutation-claims-are-executed.md`.
Two things that column still does not say. It does not say the declared clause is the ONLY thing the
guard hangs on — for three rows (measured 2026-08-22) the redness arrives through the
proof test's own "this clause has moved, RETARGET it" assertion rather than through changed
behaviour, which catches the recorded proof going stale but not much else. And one row is graded `DETECTOR` rather than `CLAUSE` in the
manifest: `instrumentation_faults` accumulates from four independent arms and a stripped hook trips
three at once, so no single-arm disarm reddens its proof. That finer mutation is carried as data and
re-run every suite, and must keep surviving — if it starts reddening, the guard has become
clause-provable and the entry is regraded.
## What the numbers say
38 guards, 6 tooling scripts, 20 proof files. **16 guards carry a mutation proof; 6 are
behaviour-only; 16 have none.** These figures are asserted against the table by
33 guards, 5 tooling scripts, 14 proof files. **7 guards carry a mutation proof; 6 are
behaviour-only; 20 have none.** These figures are asserted against the table by
`test_the_summary_counts_match_the_table` — they were wrong in the first draft (28/4/6/3/19 against
a table holding 27/5/6/3/18), because a hand-maintained summary of a table is a second copy of it,
which is the duplication family this change argues against. Both cold reviewers found the error
@@ -297,130 +184,41 @@ lines flagged wrongly — and cold review then constructed more of both (`[[ "$x
a backslash continuation, a `>` inside a quoted string). Deleted rather than patched a fifth time,
on the same reasoning as the vocabulary-parity withdrawal above.
**`test_hook_fire_log.py` proves THREE clauses, and its row claims two of them.** Coverage (every
hook reports that it fired) via `test_a_hook_that_LOSES_its_instrumentation_is_DETECTED`;
transparency (the wrapper changes nothing the harness can see) via
`test_instrumentation_changes_NOTHING_the_harness_can_see` with its mutation proof
`test_DELETING_the_replay_makes_the_differential_go_RED`; and placement (`etv_hook_fire_begin` must
precede the stdin read) via `test_begin_placed_AFTER_the_stdin_read_is_DETECTED`. The row's `Blocks`
column covers the first two — "stops reporting that it fired, **or whose reporting changes what the
harness sees**" — and the `Proof ref` column holds one ref because the column holds one, not because
the second is unproven. Placement is proved and unclaimed.
**Its `test_the_suite_does_not_write_to_the_PRODUCTION_log` is narrower than its docstring**, which
says `conftest.py` "must isolate every test, not just this file's". What it checks is that the
fixture set `ETV_HOOK_FIRE_LOG_DIR` *for the test currently running* and that a hook it drives in
its own sandbox does not touch the real log. It cannot see another suite that snapshots
`os.environ` at IMPORT time — before the autouse fixture runs — and hands that stale mapping to its
subprocesses. That suite's hooks then write to `$HOME/.cache/ersatztv/hook-fire/` while every
assertion stays green, because the fire-log library is fail-open. #785 shipped exactly that defect,
inside the file added to prove those very hooks.
The reproduction is the part worth keeping: restore a module-level `{**os.environ}` snapshot — or
leave the helper correct and point a single `env=` argument at one — then run that file and count
records for its synthetic session ids. **58 per run**, measured identically on macOS/git 2.55 and
Linux/git 2.47.3. (The accumulated total observed before the fix ran to four figures across many
runs; that is an observation rather than a reproducible measurement, so check the per-run figure.)
The pin is `test_worktree_ownership_guard.py::test_driving_a_hook_LANDS_its_records_in_the_ISOLATED_dir`,
which asserts the EFFECT — records land in the fixture's dir — rather than the shape of the fix. Its
predecessor asserted the helper's return value, and cold review showed that leaves the call site
unguarded: `_env()` correct, one `env=` reverted, all tests green, records still leaking. Pinning to
a hand-written revert rather than to the property is `verify-against-the-REAL-predecessor`.
It is still narrower than the property: it guards the launch path it drives. A second launcher in
the same file that passed a stale environment would leave it green — measured, 18 records — because
the hooks it drives would still log correctly. Every hook in that file goes through one helper
today, which is what makes it sufficient there. Generalising it is #809, and the reason that is hard
is that the obvious version races against a real session's hooks firing during the run.
**`test_hook_fire_log.py` asserts TWO clauses
**That unproven set carries no number here on purpose.** It restated the count, drifted the moment
the BOM guard was regraded, and `test_the_summary_counts_match_the_table` cannot see it — the parser
checks the formatted summary sentence and nothing else. A second hand-maintained copy of a number is
the duplication family this file argues against, so the copy is removed rather than corrected: the
set is the eight `.claude/hooks/` guards enumerated in the standing list below, and the count is in
the summary above. Naming them by event is what made the earlier wording wrong twice — they do not
share one event, and `design-sync-reminder.sh` is registered on **both** `PreToolUse` and `Stop` in
`.claude/settings.json`, so any "every X hook" phrasing double-counts it.
set is every `PreToolUse` hook except merge-consent and the BOM guard, and the count is in the
summary above.
**They are now observable but still unproven, and the two words carry different weight.** Observable:
every hook records its own execution through `scripts/hook-fire-log.sh`, so "did this hook fire, and
what did it decide" is a measurement — run `scripts/hook-fire-log.sh report` (#776). Unproven:
nobody has demonstrated any of them is load-bearing — the first group in the standing list below.
Observability tells you a guard ran; only a mutation tells you it would have caught anything. The
BOM guard is the case that shows why the distinction matters — it was firing on every commit the
whole time it was fail-open.
nobody has demonstrated any of them is load-bearing, which is what #785 tracks. Observability tells
you a guard ran; only a mutation tells you it would have caught anything. The BOM guard is the case
that shows why the distinction matters — it was firing on every commit the whole time it was
fail-open.
The gaps are not uniform in cost, and the ranking that matters is *what a silent failure would let
through*, not test count. **All four ranked entries now carry clause-level mutation proofs (#785);
they are kept here with what each mutation established, because the ranking is the reusable part and
because two of them turned out to be worse than the ranking predicted.**
through*, not test count:
1. ~~`pretooluse-bom-guard.sh`~~**proven, and it was fail-open the whole time.** Ranked first
because the defect it guards has recurred three times (#311, #402, #405); that ranking turned out
to be right for a worse reason than intended. It detected a BOM with `xxd -p`, and `xxd` ships
with vim and is **absent on the Linux CI runner**, so the comparison never matched and every BOM
was allowed in silence. `od` now. The lesson for the rows below: an unproven guard is not merely
untested, it is a guard whose *current* behaviour nobody has established.
2. ~~`pretooluse-worktree-guard.sh` + `posttooluse-worktree-marker.sh`~~ — **proven as a pair.**
Four clauses were disarmed and witnessed red: the guard's marker read; the guard's ownership
comparison (inverted, because disarming it the other way only makes the guard deny more and every
deny assertion stays green); the `commit|merge` alternation, whose `merge` half every other case
in the file left untested while guarding the plumbing-merge path; and — the one that could not
exist while the halves were tested apart — the *marker hook's write*, asserted against the
*guard's* decision. Both hooks are
deliberately fail-open, so an absent mechanism and a working one produce the identical "commit
allowed".
3. ~~`.husky/pre-push:11`'s `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE`~~ — **proven, and the case
is the normal one rather than an edge.** Git exports `GIT_DIR` to `pre-push` **when the push
comes from a worktree** and not from the main tree; `process.shared-tree-readonly` makes the
worktree the mandated way to work here, so every push takes the exposed path. With `GIT_DIR` set
and `GIT_WORK_TREE` unset git stops discovering the repo and treats the *current directory* as
the work tree, so `cd web && npm run check:api`'s `git diff --exit-code` compares against index
paths that do not exist and reports no diff. Both the deletion and the relocation are proved.
4. ~~`scripts/build_decisions_catalog.py`~~**proven, including the wiring.** The `--check`
comparison is mutated directly, and a separate subprocess case runs the command *derived from
`pr-checks.yml`* against a copied corpus. That second case is not redundant: replacing
`raise SystemExit(main())` with a bare `main()` leaves the script printing
`docs/decisions/README.md is stale` on stderr while exiting **0**, and the workflow step reads
nothing but the exit code. Only the subprocess case reddens — the #751/#719 shape.
1. ~~`pretooluse-bom-guard.sh`~~**now proven, and it was fail-open the whole time.** Ranked
first here because the defect it guards has recurred three times (#311, #402, #405); that
ranking turned out to be right for a worse reason than intended. It detected a BOM with
`xxd -p`, and `xxd` ships with vim and is **absent on the Linux CI runner**, so the comparison
never matched and every BOM was allowed in silence. `od` now, with a clause-level mutation proof.
The lesson for the rows below: an unproven guard is not merely untested, it is a guard whose
*current* behaviour nobody has established.
2. `pretooluse-worktree-guard.sh` + `posttooluse-worktree-marker.sh` — a two-file mechanism guarding
#289, where a regression in either half is invisible and the two halves have never been tested
together.
3. `.husky/pre-push:11`'s `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE` — a one-line fix for a real
bug (a nested `git diff --exit-code` silently reporting no diff) that nothing pins. Reordering it
after the nested git calls reintroduces the bug silently.
4. `scripts/build_decisions_catalog.py` — its `--check` path is what CI runs and no test calls
`main()` at all; the tests exercise `render_catalog()` directly.
**What a file-level `MUTATION` grade does and does not claim, because three of these four rows are
multi-purpose files.** The grade covers *the clause the cited case actually mutates*, per
`testing.guard-ships-with-mutation-proof`; it is not a statement about every line in the file.
`.husky/pre-push` runs four other things, and its row asserts only that line 11 is load-bearing —
the three hooks it invokes carry their own rows and their own grades. Reading the row as "pre-push
is tested" is the same relabelling error the withdrawn-guard table above exists to prevent.
Clause-level grading is tracked in #790.
**The remaining unproven guards, and why each is still `NONE` rather than merely unattended** — the
third `## Done-when` box of #785, answered here rather than left implicit. They fall into three
groups, and the split is the point: the first two are backlogs, the third is not.
- **The eight remaining hook guards** — genuinely unproven, and a real backlog. Four are
`PreToolUse` (`pretooluse-agent-model.sh`, `pretooluse-agent-ram.sh`, `pretooluse-bash-guard.sh`,
`pretooluse-nav-guard.sh`), two are pre-push (`prepush-clean-worktree-check.sh`,
`prepush-donewhen.sh`), one is pre-commit (`decisions-guard.sh`) and one is registered on both
`PreToolUse` and `Stop` (`design-sync-reminder.sh`); they are grouped by their status, not by
their event, because the events do not partition them. They are now
*observable* (`scripts/hook-fire-log.sh report`, #776), which is a weaker claim than proven and is
stated as such above.
- **`.husky/commit-msg` and `.husky/pre-commit`** — unproven, and each carries its own clause, so
they are a backlog too rather than "covered by what they call". `pre-commit` dispatches to
`lint-staged` and `decisions-guard.sh`, but the root-level-`*.png` refusal and the
`dotnet format whitespace --verify-no-changes` block are its own. `commit-msg` is not a dispatcher
at all: it is one `grep -q '^Co-Authored-By:'` plus a `MERGE_HEAD` exemption, and nothing tests
that the exemption fires only for merges. Neither is covered by what it calls — the trap is to read
"it dispatches to guards" as "its own clauses are guarded".
- **`ci-detect-already-validated.sh`, `ci-detect-docs-only.sh`, `ci-prove-ban-detects.sh`,
`e2e-functional.sh`, `check-kickoff-guard.sh`, `test_ci_release_path_scan_job.py`** — not
unattended either, but each needs its own harness rather than a proof written to clear a row.
`ci-prove-ban-detects.sh` is the exception noted in `testing.guard-ships-with-mutation-proof`: it
runs its own mutation at CI time, because what it proves is disarmable from inside pytest.
That list is prose and nothing checks it, which is the honest limit — a guard moving out of a group
will not redden anything. It is here so the next session inherits *why* a row is `NONE`, which is
the distinction #785 asked for; the machine-checked half remains the table.
Filling the rest is tracked rather than done in one pass, deliberately: a mutation proof written to
Filling these is tracked rather than done in one pass, deliberately: a mutation proof written to
close a row is the kind of test that passes for the wrong reason.
+4 -13
View File
@@ -314,12 +314,7 @@ HARD CONSTRAINTS:
compiler/parser, security, migrations, review arbitration) → orchestrator tier; independent review →
a different model family than the implementer. Omitting it silently inherits the orchestrator tier, so
state the choice out loud. → `process.per-agent-model-routing`
- **Local gate + cold-context review BEFORE the push**, never after — and the push is licensed by a
**CLEAN verdict, not by a review having run.** Zero outstanding findings on the current tree, however
many rounds that takes; "round 1's findings are fixed" and "the mechanism has been cleared" are both
reasons to keep reviewing, not to push. Each extra push auto-cancels the live run, and a cancelled job
reads as `failure` at the commit-status endpoint — so an early push manufactures phantom reds on top of
the wasted runner time. → `process.local-gate-before-push`, `ci.cancelled-is-not-a-verdict`
- **Local gate + cold-context review BEFORE the push**, never after. → `process.local-gate-before-push`
- **Independent review is mandatory** for locks/concurrency, auth/security, API write-path handlers, DB
migrations, or >~150 changed C# lines; a skip must be stated with its reason. → `process.independent-review-rubric`
- **Batch your pushes — you cannot cancel a CI run.** Only the operator can cancel, in the browser.
@@ -351,10 +346,8 @@ HARD CONSTRAINTS:
> **Scope**: *how we work* — orchestration, CI triage, review routing, cross-session hygiene. Each rule
> below is one or two lines plus its decision `key:`; the evidence, the incident that produced it and the
> full rationale live in that record (`docs/decisions/workflow-process.md` for most of them). **Keep it
> that way** — `docs.no-session-narrative` is the general rule (a doc records the end state; the path to
> it goes in the commit message), and it binds here with one extra cost on top: this file is pasted into
> every session's kickoff, so a paragraph here is a tax paid by every future session, while a record is
> retrieved only when it is needed (#542).
> that way** — this file is pasted into every session's kickoff, so a paragraph of narrative here is a tax
> paid by every future session, while a record is retrieved only when it is needed (#542).
>
> Editing: prune covered and stale bullets rather than appending — this is not append-only and git keeps
> the history. If you add a rule, write the record first and cite it here.
@@ -393,9 +386,7 @@ HARD CONSTRAINTS:
**Reading a red CI run** (check these before diagnosing your diff)
- A **killed** job reports `conclusion: failure` — read the log tail for the `❌ Failure - Main` marker;
log timestamps are UTC, the host is UTC+2. → `ci.killed-job-triage`
- **`cancelled` is not a verdict**, and the endpoint you poll hides it: `commits/{sha}/status` has no
`cancelled` state and reports one as `failure`. Resolve the job `conclusion` via
`actions/runs/{id}/jobs` before believing a red. → `ci.cancelled-is-not-a-verdict`
- **`cancelled` is not a verdict** — report FAILED and CANCELLED separately. → `ci.cancelled-is-not-a-verdict`
- A failure inside a **setup/cache step**, before your code compiles, is environmental.
`ci.infra-shaped-red-under-load`
- A lone **`decisions lifecycle`** red is a known flake: do **nothing**, the operator reruns it. A
-189
View File
@@ -1,189 +0,0 @@
# Remote-state inventory (ersatztv#778)
Every **git-tracked** file matching one of these, that reads live remote state and later acts on
that read, and whether the read is bound to something that cannot change underneath it:
| Directory | Files |
|---|---|
| `scripts/` (recursive, **excluding `scripts/tests/`**) | `*.sh`, `*.py` |
| `.claude/hooks/` | `*.sh` |
| `.husky/` | all tracked files |
| `.gitea/workflows/` | `*.yml`, `*.yaml` |
The per-directory extensions are stated because the prose once attached them to `scripts/` alone
while the guard applied them everywhere, so a `.py` hook would have joined the scope the doc
described and acquired no row.
The scope qualifier is load-bearing, not throat-clearing: this file twice claimed to cover "every
executable in this repo" while its own derivation missed real remote readers, so the heading now
states exactly what the guard enforces — including the `scripts/tests/` exclusion, so nobody adds a
remote-reading test executable expecting a red guard that will stay green. C#/TypeScript guards,
`web/`, and anything outside those directories are **not** covered. Scope is limit 3; the
files-not-call-sites limit is limit 2.
`scripts/tests/test_remote_state_inventory.py` derives the population from `git ls-files` and
asserts **set equality** against the `Site` column, so a new script that talks to a remote service
cannot ship without acquiring a row.
**Read `docs/decisions/records/process/check-and-use-pins-a-version.md` before adding a row or
changing a classification.**
## Why this file exists rather than a linter
`docs/defect-shapes-773.md` §4 detector D is a **fix pattern, not a mechanical detector**: there is
no general lint for "this code should have pinned a sha." What makes the class actionable is that
the population is small and enumerable, so the detector is detector A — derive the population from
an authoritative source and assert set equality — applied to this inventory. The inventory is the
artifact; the test keeps it from rotting.
## Columns
- **Class**
- `PINNED` — the read is bound to an immutable version identifier (a **full** commit sha, an
image digest, a monotonic event count), and that binding still holds when the action runs. Two
shapes qualify, and the second was missing from the first wording: either the action
**re-validates** against the identifier immediately before committing (a snapshot nobody
re-checks is not pinned — binding alone is never enough), **or** the check and the use are a
single step over a value that cannot move, such as a workflow reading a full sha straight out of
its own fixed event payload. What never qualifies is a value captured early and trusted later.
The second shape is distinguished from an `N/A` row that says "resolution and use are one step"
by the IDENTIFIER, not by the step count: `PINNED` requires the value itself to be immutable (a
full sha, a digest), while a one-step read of a MUTABLE identifier — a registry tag, a branch
name — is not pinned and is graded on what it authorizes.
- `CAS` — the write itself carries a compare-and-set condition the server enforces.
- `UNSAFE-KNOWN` — read-then-act with nothing pinning it, **accepted** with the reason stated in
the Note. Every row here must say why the residual is tolerable, not merely that it exists.
- `N/A` — reads no live remote state, or draws no authorization from what it reads.
- **Note** — the window, and what closes or bounds it.
A row is about a *site*, not a file: a file with two independent reads gets two rows only where the
classifications differ; otherwise the strictest applies and the Note names the exception.
## The inventory
### Hooks
| Site | Class | Note |
|---|---|---|
| `.claude/hooks/pretooluse-merge-consent.sh` — head-sha reads (CI status, H10 status, verdict comments) | `UNSAFE-KNOWN` | Every comparison uses the **full** 40-char `.head.sha` (`${sha:0:7}` appears only in human-readable strings), which is the right identifier — but it is captured ONCE from the PR snapshot at the top of the hook, and is stale in two distinct ways. FIRST, within the run: every later check (CI status, H10 status, verdict comments) is evaluated against that captured sha, so a push landing mid-run is checked against the commit it replaced. This is the same defect that WAS live for `$base_ref` until it was re-read before the branch-protection lookup; the sha is not re-read, and closing it symmetrically is tracked in **#803**. SECOND, after the decision: the hook returns `allow` and a separate call performs the merge. "No async window" was the second overclaim cold review removed from this file. Both are bounded the same way — a head the verdict does not cover cannot inherit the sha-bound required status, so the server refuses it — and neither is bounded by anything in this hook. The merge API accepts an optional `head_commit_id`, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head. |
| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — graded down by cold review, which was right: the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` contradicted this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check, reading the repo's **full rule list** (never the by-name endpoint, which does no matching and knows nothing about precedence) — nothing can govern the base → deny, unreadable → ask, and a **glob rule that could govern it → ask**, because the hook deliberately does not reimplement Gitea's glob dialect. It also asks when two rule names fold equal, or when either name is non-ASCII, since Gitea's `EqualFold` is Unicode-aware and its rule precedence is not derivable here. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted, and the earlier wording here was **circular** — it said the residual was "closed one layer down" by the very branch protection an admin may have removed. It is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. |
| `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `UNSAFE-KNOWN` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632), and it deliberately compares the base **ref** rather than `base.sha` — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook. |
| `.claude/hooks/pretooluse-merge-consent.sh``## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. |
| `.claude/hooks/prepush-donewhen.sh``## Done-when` issue-body read | `UNSAFE-KNOWN` | No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: `main` carries `enable_push: false` and `block_admin_merge_override: true`, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. |
| `.claude/hooks/pretooluse-nav-guard.sh` | `N/A` | Reads only the proposed tool call's own parameters and decides synchronously; the `curl` mentions in the file are prose, not executed lines. |
| `.claude/hooks/prepush-rebase-check.sh``git fetch origin main`, then `git merge-base --is-ancestor origin/main HEAD` to decide whether to block the push as behind | `UNSAFE-KNOWN` | Fetch-then-decide with no re-validation before the verdict; `origin/main` can advance inside that window. Accepted because the decision is self-correcting and cannot reach `main`: a push allowed on a now-stale read still lands on a feature branch, since direct pushes to `main` are refused server-side (#743), so the worst case is a rebase nag arriving one push later rather than a bad merge. This row exists because the previous token filter did not list `git fetch` and so could not see it at all. |
| `.claude/hooks/prepush-clean-worktree-check.sh``git fetch origin main`, then diffs `origin/main...HEAD` to scope which dirty files are in the pushed diff | `UNSAFE-KNOWN` | Same fetch-then-decide shape and the same bound: the verdict advises a feature-branch push only, `main` refuses direct pushes server-side (#743), and a stale `origin/main` read at worst lets a dirty-file push through, which the downstream review gate still catches before any merge. |
| `.claude/hooks/decisions-guard.sh` | `N/A` | Reads no live remote state — runs `scripts/decisions_validate.py` over the local working tree; no fetch, no HTTP call. |
| `.claude/hooks/design-sync-reminder.sh` | `N/A` | Reads no live remote state — compares local `git diff`/`ls-files` output against the session's own edits, never contacts origin. |
| `.claude/hooks/posttooluse-worktree-marker.sh` | `N/A` | Reads no live remote state — parses the tool call's own JSON payload and writes a local ownership marker. |
| `.claude/hooks/pretooluse-agent-model.sh` | `N/A` | Reads no live remote state — inspects only the proposed Agent call's own `model`/`subagent_type` fields. |
| `.claude/hooks/pretooluse-agent-ram.sh` | `N/A` | Reads no live remote state — samples local `memory_pressure -Q` output. |
| `.claude/hooks/pretooluse-bash-guard.sh` | `N/A` | Reads no live remote state — pattern-matches the proposed Bash command string for `ETV_UPDATE_GOLDENS=`. |
| `.claude/hooks/pretooluse-bom-guard.sh` | `N/A` | Reads no live remote state — inspects local `git diff` output and reads local `.cs` bytes for a BOM. |
| `.claude/hooks/pretooluse-worktree-guard.sh` | `N/A` | Reads no live remote state — reads a local `.claude-worktree-owner` marker in the target worktree. |
### Husky git hooks
| Site | Class | Note |
|---|---|---|
| `.husky/pre-push` | `N/A` | Delegates every remote read to `prepush-donewhen.sh`, `prepush-rebase-check.sh` and `prepush-clean-worktree-check.sh`, each of which carries its own row. This file forwards stdin ref lines and runs local `npm run check:api/lint/typecheck/build` against the checked-out tree. |
| `.husky/pre-commit` | `N/A` | Reads no live remote state — local lint-staged, `decisions-guard.sh`, a `git diff --cached` scan, and local `dotnet format --verify-no-changes`. |
| `.husky/commit-msg` | `N/A` | Reads no live remote state — greps the local commit-message file for a trailer. |
### Scripts
| Site | Class | Note |
|---|---|---|
| `scripts/post-review-verdict.sh` — commit-status write | `PINNED` | Re-reads the PR and compares **both** `.head.sha` and `.base.ref` immediately before the POST, and `die`s (exit 1, no status written) on a mismatch **or on a field it cannot read**. That last clause is new: both comparisons were guarded by `[ -n "$x" ] &&`, so a well-formed 2xx body that merely omitted the field made the check a no-op and the status was posted having confirmed nothing — found by cold review on #778 and regression-tested against the real predecessor, since the redundant `-z` arm alone mutates green. Closes #706 and #632 for this path by read-compare-refuse, not by CAS: Gitea's status API offers no conditional write. Residual closed on the write ORDER since #792: the status is written first and the comment second, so a refusal can no longer leave a verdict comment with no status behind it — the only reachable half-state is a status with no comment, which the merge hook resolves as `ask` (`release.verdict-writes-status-before-comment`). |
| `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix, graded honestly after cold review: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases; this row previously said "any movement fails", which was stronger than the code. Accepted here because the enumerator cannot close it alone, but be exact about what the caller-side fence does and does not cover: `ci.verdict-write-retarget-fence` counts `change_target_branch` events, so it catches the BASE alias and **nothing else**. A HEAD alias is not covered by anything — a force-push `H1 -> H2 -> H1` during pagination leaves the final `.head.sha` comparison equal while the middle pages were enumerated against `H2`, and no counter moves. That residual is real, unfenced, and stated here rather than papered over; closing it needs a monotonic head-mutation fence or enumeration bound to an immutable tree, neither of which exists today — tracked in **#803**, which also carries the three older contracts that still assert more than this row does. |
| `scripts/select-queue.sh` — issue list, then per-issue `/dependencies` | `UNSAFE-KNOWN` | The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed `in-progress` in that gap still appears on the shortlist. Accepted: the script authorizes **no write**. The real gate is the four-way claim check in `process.parallel-session-claim`, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory. |
| `scripts/ci-detect-already-validated.sh` — prior-head combined status | `UNSAFE-KNOWN` | Reads the PR head's status and emits `skip=true`, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only **re-running** test/migrations on a tree already validated; the `build` job still builds and pushes unconditionally, so no image ever ships from unvalidated source. |
| `scripts/issue-qualification-audit.sh` — paged issue list, then a report | `UNSAFE-KNOWN` | Pages the open-issue list and reports which issues lack a `priority:` label, so like every paged read here its pages can straddle a change and the report can name a state no single instant held. Graded to match `select-queue.sh` rather than `N/A`: the two run the same shape, and the reason offered for accepting `select-queue.sh` — it authorizes no write — cannot simultaneously be the reason this one is out of the class. Accepted on the same terms: it is advisory, session-end, human-read, and the labels it prompts for are applied by hand afterwards. |
| `scripts/security-scan.sh``docker pull`, then `docker run` the same tag | `UNSAFE-KNOWN` | Pull and run are two steps over a MUTABLE tag, which is the same shape the registry rows below were graded down for; "resolution and use are one step" overstated it. In practice the second step resolves against the local daemon, which holds the image the pull just placed, so a mid-window retag does not change what runs. Accepted on that, plus the scope: this boots a throwaway container and scans it, authorizing nothing. |
| `scripts/migration-smoke.sh``docker pull`, then `docker run` the same tag | `UNSAFE-KNOWN` | The same pull-then-run over a mutable tag as `security-scan.sh` above, and graded with it rather than left behind: its row previously said "Same shape" as a note that has since been rewritten to the opposite conclusion, so the backreference had quietly inverted. This one deserves the grade MORE, not less — `security-scan.sh` boots a throwaway container and authorizes nothing, while this is the pre-deploy migration smoke that gates a production stack recreation. Accepted on the same bound (the run resolves against the local daemon holding the image the pull just placed) plus its own stated operator-trust gap: the resolved image id is **reported** for a human rather than compared against a prior read. |
| `scripts/hook-fire-log.sh` | `N/A` | Entirely local: reads stdin and writes JSONL under the cache dir; the only `curl` in the file is in a comment. |
| `scripts/e2e-local.sh` | `N/A` | No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started. |
| `scripts/e2e-ui.sh` | `N/A` | Launches a local Chromium and runs specs against `http://localhost:$PORT`. |
| `scripts/e2e-functional.sh` | `N/A` | Every call targets `$BASE_URL`, defaulting to `http://localhost:8409`. The one non-local-looking address, `192.0.2.1`, is TEST-NET-1 (RFC 5737) — written into the DB as a connection row precisely so it is unroutable, never dialed by the script. |
| `scripts/ci-detect-docs-only.sh``git fetch origin "$base"`, then diffs the fetched tip against HEAD to emit `docs_only`, which gates whether the required test/migrations jobs run their real steps | `UNSAFE-KNOWN` | Read-then-act with no re-check between the fetch and the emitted value, and the decision genuinely gates required CI work. Accepted because the script is deliberately asymmetric: every ambiguous, undeterminable or shallow-checkout case resolves to `docs_only=false` (run everything), and only an exact unanimous all-docs diff yields `true` — so a stale or racing base read can at worst cause an unnecessary full run, never a skipped one (#416). |
| `scripts/refresh-shared-checkout.sh``git fetch origin main`, then `git merge --ff-only origin/main` and a conditional `npm ci` | `UNSAFE-KNOWN` | Fetch-then-act with no re-check between the fetch and the merge. Accepted because every action is self-refusing or reversible: `--ff-only` fails harmlessly rather than diverging if the ref moved on, the script refuses outright when the tree is not clean `main` or is ahead or mid-rebase, and this is a developer-convenience checkout rather than a release or merge-authorization path — a stale read costs one extra fetch next run, never lost work. |
| `scripts/check-review-verdict.sh` | `N/A` | Reads no live remote state itself — classifies a comments JSON payload supplied on stdin; the fetch belongs to the caller's row. |
| `scripts/decisions_validate.py` | `N/A` | Reads no live remote state — its `git log`/`show`/`ls-tree`/`merge-base` calls operate on refs the caller already checked out or passed via `--base`/`--head`, never a fetch. |
| `scripts/prove-fix.sh` | `N/A` | Reads no live remote state — `git worktree add`/`rev-parse`/`diff-tree` operate on the local repository's own objects. |
| `scripts/add-migration.sh` | `N/A` | Reads no live remote state — runs `dotnet ef migrations add` against local project files; implicit NuGet resolution is dependency supply-chain, out of this class per limit 1. |
| `scripts/update-openapi.sh` | `N/A` | Reads no live remote state — a local `dotnet build`/`GenerateOpenApiDocuments` then a local python script. |
| `scripts/cleanup-code.sh` | `N/A` | `dotnet tool restore` resolves and uses tooling in one step (limit 1, not a check-and-use split); the rest is a local `git status --porcelain` scan. |
| `scripts/cleanup-all-code.sh` | `N/A` | Same shape as `cleanup-code.sh` — restore-and-use in one step, no check-then-act over remote state. |
| `scripts/build_decisions_catalog.py` | `N/A` | Reads no live remote state — parses local decision records and writes the local catalog. |
| `scripts/decisions_lib.py` | `N/A` | Reads no live remote state — pure parser over local decision-record files. |
| `scripts/migrate_decisions_split.py` | `N/A` | Reads no live remote state — one-shot local file migration over `docs/decisions/`. |
| `scripts/generate-endpoint-index.py` | `N/A` | Reads no live remote state — reads local `v1.json` and writes a local markdown index. |
| `scripts/check-doc-narrative.py` | `N/A` | Reads no live remote state. Both modes are local: `--diff` reads `git diff` against a ref the CALLER fetched, `--all` reads `git ls-files`. The `docs-reminder` fetch that supplies the ref carries its own row below. |
| `scripts/check-kickoff-guard.sh` | `N/A` | Reads no live remote state — scans a fixed local file list for forbidden phrasing. |
| `scripts/check-local-lsp.sh` | `N/A` | Reads no live remote state — probes local PATH binaries and spawns a local MCP server over stdio. |
| `scripts/mcp_smoke.py` | `N/A` | Reads no live remote state — spawns a local subprocess and speaks JSON-RPC over stdio pipes, no network socket. |
| `scripts/jq-preflight.sh` | `N/A` | Reads no live remote state — runs local `jq --version`. |
| `scripts/ci-peak-anon.sh` | `N/A` | Reads no live remote state — samples the runner's local cgroup `memory.stat`/`memory.peak`. |
| `scripts/ci-prove-ban-detects.sh` | `N/A` | Reads no live remote state — mutates a local workflow copy and runs pytest against the local checkout. |
| `scripts/ci-step-ran.sh` | `N/A` | Reads no live remote state — reads runner-supplied env vars and local marker files it wrote itself. |
| `scripts/ci-toolchain-image-resolves.sh` — registry manifest read for the pinned toolchain tag | `UNSAFE-KNOWN` | Reads a MUTABLE identifier (a registry tag) with nothing re-checking it before the `container:` jobs pull, so a tag deleted between the preflight and the pull is reported as present. Graded `UNSAFE-KNOWN` rather than `N/A` deliberately: nothing proceeds on the strength of the read — it can only turn its own job red, which is not nothing (the merge-consent hook denies on the COMBINED status, ersatztv#598) but is not authorization either — while a stale PASS is read by a human as "the image is fine", which is an assertion about remote state this file exists to grade. The residual is bounded by what it degrades to: a stale pass leaves exactly the pre-#772 behaviour (five jobs failing at pull), never anything that proceeds on the strength of the read. The opposite error is closed by the EXIT CODE rather than by wording: an unusable credential, an unverifiable answer (after retries) and an HTTP 200 whose body is not a manifest all FAIL the job. THE TRAP, since warning on those and exiting 0 is the natural way to write this check: a missing `curl`, a moved registry or a DNS change all land there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after — "the check could not run" presenting as "the pin is fine", which is precisely what this row would then be asserting falsely. |
| `scripts/set-provider.sh` | `N/A` | Reads no live remote state — sets local `dotnet user-secrets` values. |
| `scripts/__init__.py` | `N/A` | Empty package marker — executes nothing. |
| `scripts/scripted-schedules/entrypoint.py``ScriptedScheduleApi.get_context(build_id)`, then `define_content` / `reset_playout` / `build_playout` against the same live server | `UNSAFE-KNOWN` | A genuine read-then-act over live ErsatzTV state, and the row cold review found missing when the population was still non-recursive. The context is fetched, handed to user-supplied script functions that mutate the playout, and re-fetched after a reset with nothing pinning either read — a concurrent build or edit between them is invisible. Accepted because it runs inside a single scripted-schedule build the server itself serialises per playout, and because the API exposes no version or ETag on the context to compare against; the honest bound is that the blast radius is one playout's content, reversible by rebuilding. |
| `scripts/macOS/bundle.sh` | `N/A` | Reads no live remote state — moves files and creates symlinks in a local app bundle. |
| `scripts/macOS/sign.sh` | `N/A` | `codesign --timestamp` contacts Apple's timestamp server, but resolution and use are one step with no earlier check whose answer is later trusted — the same boundary as limit 1. |
| `scripts/macOS/sign-dmg.sh` | `N/A` | Same shape as `sign.sh` — a timestamped `codesign` over a local DMG, no check-then-act over remote state. |
### Workflows
| Site | Class | Note |
|---|---|---|
| `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed twice rather than claimed closed: a monotonic `change_target_branch` **event-count** fence refuses to write if the count moved (`ci.verdict-write-retarget-fence`; the count is used because the branch *name* is ABA-vulnerable), and a high-water-mark re-read repairs a `success` posted over a human verdict back to `pending`. The file states the residual window explicitly rather than asserting safety. |
| `.gitea/workflows/review-verdict.yml` — base-ref checkout | `PINNED` | `ref: ${{ github.event.pull_request.base.sha }}` — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. |
| `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `UNSAFE-KNOWN` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base, and therefore **inherits that row's residual, not a pin** — this row said "inherits that row's pins" while the row it points at was being graded down, which is exactly the stale-cross-reference a multi-round edit produces. Accepted on better terms than the enumerator alone — this is the one caller that also runs the monotonic `change_target_branch` event-count fence (`ci.verdict-write-retarget-fence`) — but only for the BASE axis. The HEAD alias described in the enumerator's row (`H1 -> H2 -> H1` during pagination) is unfenced here too, and this row previously implied the fence covered it. |
| `.gitea/workflows/docker-build.yml` — CI toolchain image | `UNSAFE-KNOWN` | Graded down by cold review, correctly: this file's own definition of `PINNED` names an image **digest**, and `ersatztv-ci:<short-sha>` is a mutable **tag**. A registry tag can be repointed after `ci-image-pin` verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume `:latest`, and `pr-checks.yml`'s `ci-image-pin` fails the build if the tag drifts from the last commit touching `docker/ci/`. Consuming `image@sha256:…` is the real fix and is the natural companion to **#772**, which already covers the availability half of this tag's weakness. |
| `.gitea/workflows/docker-build.yml` — release smoke pull | `UNSAFE-KNOWN` | Pulls `${IMAGE}:${SMOKE_SHORT_SHA}`, the tag this same job pushed moments earlier. The first draft called that `PINNED` on the strength of the job's `concurrency` group; cold review showed the group is **per-ref**, so a branch build and a tag build of the same commit sit in DIFFERENT groups and can publish the same `:<short-sha>` — the smoke step can therefore pull the other run's image. Accepted rather than fixed here because the fix is the same one-line change as the row above (pull `image@sha256:…`, propagated from the push step) and belongs with it; until then no registry row in this file claims to be pinned. |
| `.gitea/workflows/docker-build.yml``api-docs` / `format` base fetch | `UNSAFE-KNOWN` | Fetches the live base tip to diff generated artifacts, with nothing pinning it. Graded up from `N/A` by cold review, which was right that "advisory" was too quick: these are not branch-protection-required contexts, but the merge-consent hook reads Gitea's **combined** status, and a combined state that is not `success` blocks the auto-grant — so a wrong answer here does participate in merge consent. Accepted because the failure direction is benign: a base that advanced mid-job makes a generated artifact look stale and FAILS the job, costing a re-run, rather than passing something it should not. |
| `.gitea/workflows/ci-image.yml` — verify the pushed image | `UNSAFE-KNOWN` | Pulls back the `:<sha>` tag it pushed in the immediately preceding step. Same demotion and same reason as the two rows above: a registry tag is not a digest, and anything able to write to the registry can repoint it between the push and the verify, which would make the verification confirm an image other than the one built. Accepted on the same terms — the exposure requires registry write access, i.e. an actor already inside the trust boundary — and the same fix applies. |
| `.gitea/workflows/pr-checks.yml``prove-fix` | `PINNED` | `base`/`head` are full shas from the event payload, immune to later PR mutation. |
| `.gitea/workflows/pr-checks.yml``ci-image-pin` | `N/A` | A purely local comparison — `git log` over the checked-out tree against a literal in `docker-build.yml`. It never queries the registry, which is exactly why it cannot bound the retagging the image rows above describe. |
| `.gitea/workflows/pr-checks.yml``docs-reminder` base fetch | `N/A` | Fetches the live base tip, but the result only selects the text of a non-blocking warning. The rationale has to be narrower than the first draft's "the job cannot fail and never reaches the combined status", which is false — any job's status joins the combined state, and runner or checkout failure can redden it. What is true, and is what earns the `N/A`: the fetch and diff are failure-swallowed, so the remote read cannot change this job's outcome, only the warning's wording. It draws no authorization from what it reads, this file's second `N/A` clause. Grouping it with `decisions-guard` over-demoted it, and the two have different classifications, so per the rule above they get separate rows. Since ersatztv#784 the job runs TWO advisory checks off this fetch (the parity-doc reminder and `scripts/check-doc-narrative.py`); the classification is unchanged because the second is advisory on the same terms — it exits 0 on every path, so a moved base still only changes warning text (its unproven arms are enumerated in `docs.no-session-narrative`). |
| `.gitea/workflows/pr-checks.yml``decisions-guard` base fetch | `UNSAFE-KNOWN` | Fetches the live base tip and diffs against it, unpinned, and unlike `docs-reminder` this job CAN fail — so its answer reaches the combined status the merge hook reads and therefore participates in consent. Accepted on the same grounds as the `api-docs`/`format` row: a base that moved mid-job produces a spurious FAILURE and a re-run, never a spurious pass, and it re-fetches fresh on every trigger with no state carried between runs. |
| `.gitea/workflows/renovate.yml``renovate/renovate:43` | `N/A` | Out of this class, in scope for a different one — see the limits below. |
| `.gitea/workflows/dependency-scan.yml` — NuGet advisory query | `N/A` | `dotnet list package --vulnerable` queries a live advisory database and fails the job on the report marker in the same step, so there is no check-then-act split. Worth one line anyway: a green here means "no advisories **as of this run**", which is a dated claim about mutable remote data rather than a property of the tree — which is why the scan runs on a weekly schedule instead of only on PRs. |
## Limits, stated rather than implied
1. **Unpinned dependencies are a different class and are not graded here.** `actions/checkout@v4`,
`docker/build-push-action@v6`, `mysql:8.4` and `renovate/renovate:43` are floating tags, and
`renovate.yml` runs its one with a repo-writing token. But resolution and execution are the same
step — there is no earlier check whose answer a later action trusts — so they are a
supply-chain-pinning concern, not a check-and-use race. Listing them as `N/A` here records that
they were examined and classified, not that they are safe.
2. **The population is files, not call sites.** The test derives which *files* are in scope; it
cannot tell that an existing file grew a second, unpinned read. That residue is the
sites-in-code limit named in `testing.guard-derives-population-from-source` — it needs
find-all-references tooling, tracked in #777 — and it is why the Note column is prose a reviewer
reads rather than a field a script checks.
3. **Scope is hand-written; the population inside it is derived, with no content filter at all.**
Scope is every file under `scripts/` (`*.sh`, `*.py`), `.claude/hooks/`, `.husky/` and
`.gitea/workflows/` — a reviewed policy choice. Inside it, *every* file gets a row, and a file
that reads no remote state earns an explicit `N/A` rather than silently staying out.
The first version filtered that scope by an outbound-network token list and argued the filter was
a scope choice rather than a population filter. Cold review rejected the distinction, and the
evidence settled it: the list omitted `git fetch`, which is this repo's most common remote read,
so `prepush-rebase-check.sh` — which fetches `origin/main` and derives a **push decision** from
it — was structurally invisible to a guard claiming to cover "every executable that reads live
remote state", along with three others. The defence offered was that over-inclusion is the safe
direction; the filter also *under*-included, which is the direction that costs a blind spot.
Enumerating the directories costs more rows and has none.
4. **Nothing here checks that a `PINNED` claim is true.** The test asserts every in-scope file has a
row and that the classifications come from a closed vocabulary. Whether a row is honest stays a
review responsibility, and this table is what review reads — the same split, and the same
admitted residue, as `docs/guard-inventory.md`.
-46
View File
@@ -446,52 +446,6 @@ fresher edit:
(authoritative) — never let an out-of-range value reach the server and surface a raw 400. Mirror the
server's bound as a shared const (e.g. multi-collection weight `WEIGHT_MIN`/`WEIGHT_MAX` = 1..1000, #404).
## 4b. Full-replace request bodies are built as `Complete<T>` (#807)
A full-replace endpoint writes the WHOLE entity, so a field the builder never sets is not left
alone — it is written as its default. Build every full-replace body against `Complete<T>`
(`web/src/api/completeRequest.ts`), which maps a generated request type so **every** member is
required:
```ts
function toReplaceRequest(draft: Draft): Complete<ReplaceBlockRequest> { … }
items.map((item): Complete<DecoTemplateItemRequest> => ({ … }))
```
Two rules, and the second is the one that gets skipped:
- **Annotate the wrapper parameter** (`body: Complete<ReplaceBlockRequest>`) so every caller —
including ones written later by someone who never read this — inherits the check.
- **Annotate each construction site too, including every `.map` callback's return type.** The
wrapper annotation catches a *missing* field anywhere. The *phantom* direction (a field the
schema does not accept) relies on TypeScript's excess-property check, which fires only on a
**fresh object literal in a contextually typed position** — and a literal returned from a generic
`.map` callback is not one, because `map<U>` infers `U` from the callback's return rather than
from the target element type. A spread or an inferred local loses it too, but the `.map` callback
is the common case and the easy one to misread as safe: several construction sites accepted a
phantom field before #807, and three of them contained neither a spread nor an inferred local.
An optional member may be written `field: undefined`. The point is not to forbid omitting a value,
it is to forbid omitting the *decision*: an unmentioned field is an oversight, an explicit
`undefined` is a choice a reviewer can see.
**Do NOT apply `Complete<T>` to a schema whose optional members are computed server-side.**
`ArtworkContentTypeModel` (reachable from `PUT /channels/{id}` via `UpdateChannelRequest.logo`) has
`isExternalUrl` / `hasContentType` / `urlWithContentType` as get-only properties derived from
`path`. Nothing deserializes them, so omitting them drops nothing — and annotating the site would
force you to invent server-computed values in an outbound request. Check the schema's disposition
in `scripts/tests/test_optional_request_members.py` before annotating a new site.
Why this is needed even though most builders already typecheck: a member is omittable exactly when
the generated type marks it `?:`, which comes from the `required` array of the schema in
`ErsatzTV/wwwroot/openapi/v1.json` — the generator script only passes it through. Most nullable
properties emit as required-and-nullable, so the gap looks closed on inspection while a minority
sits unchecked inside it. Do not trust a list of which schemas those are: two hand-written ones
were wrong (#807). `scripts/tests/test_optional_request_members.py` derives the set on every run
and fails until each has a stated disposition. See
`docs/decisions/records/testing/full-replace-asserts-field-list.md`, and
`web/src/api/completeRequest.guard.test.ts` for the executed proof.
## 5. Artwork rendering
Render `item.artwork` / `item.poster` (or whatever the DTO field is named) **directly as an `<img
-59
View File
@@ -1,59 +0,0 @@
# Ruff configuration for this repo's Python surface (all of it lives under `scripts/`).
#
# WHY THIS FILE EXISTS (ersatztv#780). Without a committed config, ruff falls back to whatever
# `~/.config/ruff/ruff.toml` the operator's machine happens to have — so a second machine lints this
# repo differently, or not at all. That is the environment-divergence class #643/#647/#648 (a shell
# gate whose behaviour was a function of an untested interpreter version) and #512 (a test that
# passed on a fast laptop and flaked on a starved CI VM). The settings below are pinned HERE so the
# lint verdict is a property of the repo, not of the machine.
#
# It is enforced by the `script-tests` job (`Script lint and tests (ruff + pytest)`) in
# .gitea/workflows/pr-checks.yml. A config nobody runs is the same divergence one step later.
#
# That job does NOT invoke `ruff check .`: it passes an explicit population from `git ls-files` with
# `--no-force-exclude`. An `exclude` added to this file silently empties a discovery-based run into a
# GREEN one — a top-level `exclude` empties both commands, one under `[lint]` empties `check`, one
# under `[format]` (where an appended line lands, by TOML rules) empties `format --check`. Adding
# `exclude` here will therefore not do what you expect, which is the point. The measured matrix is in
# `ci.python-lint-ruff-config-committed`.
#
# `pyright` is deliberately NOT gated: its only findings here are `reportMissingImports` for
# `etv_client` in scripts/scripted-schedules/entrypoint.py, which resolves only inside that script's
# deploy environment, and gating it would put a node toolchain on the git-only `small` lane for zero
# real findings. Revisit if this repo grows a typed Python surface.
target-version = "py311"
line-length = 120
[lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"SIM", # flake8-simplify
"S", # flake8-bandit (security)
# RUF100 is load-bearing, not tidiness: every `# noqa` below is an assertion that a real finding
# is being suppressed for a stated reason, and without this a suppression that suppresses nothing
# stays in the file reading as one. #780 did exactly that mid-branch — a `# noqa: UP031` on a site
# the same branch had already fixed in code — and found two more already in the tree: one whose
# rule had stopped firing, one for a rule this config never enables.
"RUF100",
]
ignore = [
"S603", # subprocess call - check for execution of untrusted input (too noisy for scripts)
"S607", # starting a process with a partial executable path
]
[lint.per-file-ignores]
# scripts/tests asserts, so S101 would fire on every test. S105 is deliberately NOT exempted here:
# the eight sites that trip it (`env["ETV_GITEA_TOKEN"] = "stub"`) carry a per-site `# noqa: S105`
# instead, so a real credential pasted into a fixture next year still reddens the gate. A directory
# blanket would have given up hardcoded-credential coverage over the largest Python surface in the
# repo, permanently, to suppress eight known lines.
"scripts/tests/**" = ["S101"]
[format]
quote-style = "double"
-260
View File
@@ -1,260 +0,0 @@
#!/usr/bin/env python3
"""ersatztv#784 — ADVISORY nudge for `docs.no-session-narrative`.
A doc records the END STATE; the path to it belongs in the commit message, not the artifact.
THIS NEVER FAILS. Every path returns exit 0 including a bad argument, an unresolvable base ref,
an unreadable file and an unhandled exception. That is a design constraint, not an oversight: a
narrative detector is a string predicate over prose, and `docs/defect-shapes-773.md` §4 plus
`testing.guard-derives-population-from-source` both argue that class must not be load-bearing (the
withdrawn `test_review_verdict_vocabulary_parity.py` six review rounds, then deleted is the
empirical case). Do not convert this into a gate; the decision record says no in as many words.
WHY PYTHON AND NOT SHELL. The first implementation hand-parsed `git diff -U0` output in bash by
matching line prefixes, and cold review demonstrated four separate defects in that one parser: the
`\\ No newline at end of file` marker was counted as content, an added line whose own text began
`++ ` was eaten by the `+++ ` header arm, `core.quotePath` hid non-ASCII paths, and `read` dropped a
final unterminated line. Those are four instances of one mistake deciding what a diff line IS from
its prefix alone, with no hunk state. Patching them one at a time is the shape this repo has
recorded as never converging, so the mechanism was replaced rather than the sites.
check-doc-narrative.py --diff <base-ref> scan lines ADDED against <base-ref> (the CI mode)
check-doc-narrative.py --all scan the whole tracked corpus (deliberate sweep)
Scanning only ADDED lines in CI is what keeps the existing corpus of legitimate history out of the
output; `--all` deliberately reports all of it, for a human to apply the who-benefits test to.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
# The never-fails invariant must not depend on the ambient locale. Both the summary line and the
# last-resort handler below carry non-ASCII text, so under ascii/latin-1 stdio the very code meant to
# guarantee exit 0 is what raises. Degrade unencodable characters instead of failing on them.
try:
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
sys.stderr.reconfigure(errors="replace") # type: ignore[union-attr]
except Exception: # noqa: S110 — a stdout that cannot be reconfigured is not a reason to fail
pass # deliberate: this is the never-fails invariant's own setup, so it cannot itself raise
# `docs/decisions/**` is exempt WHOLESALE — a decision record narrating how a rule was got wrong is
# carrying the rationale it exists to carry, so a detector that flagged it would fight the
# convention it serves.
EXEMPT_PREFIXES = ("docs/decisions/",)
# Session-narrative phrasings. Deliberately narrow: each is first person or names a revision of THIS
# artifact. Broad words that also appear in legitimate dated history ("previously", "was wrong") are
# absent on purpose — a false positive on a carved-out case is what makes an advisory check stop
# being read.
PATTERNS = re.compile(
r"an earlier draft"
r"|earlier drafts"
r"|the (?:first|previous|original) (?:version|draft) of (?:this|the)"
r"|my first attempt"
r"|I (?:initially|first|originally|then) (?:thought|assumed|tried|wrote|found)"
r"|we (?:then|initially) (?:found|thought|realis|realiz)"
r"|it turned out that"
r"|earlier today"
r"|as of just now"
r"|currently investigating",
re.IGNORECASE,
)
WARNING = (
"{path}:{line} reads as session narrative — a reader coming cold never saw the earlier draft. "
"Answer the review finding in the COMMIT MESSAGE and let only the corrected claim enter the "
"doc (docs.no-session-narrative). Keep it only if a reader would ACT differently knowing it "
"(dated measurement, stated snapshot boundary, tested-and-rejected result, a trap and its "
"consequence). Line: {text}"
)
def is_scanned_path(path: str) -> bool:
"""The population: `docs/**/*.md` minus `docs/decisions/**`, plus root-level `*.md`.
Stated positively and in one place so the record's `mechanics:` can quote it exactly. Skills,
`web/`, and other nested markdown outside `docs/` are deliberately NOT in scope.
"""
if not path.endswith(".md"):
return False
if any(path.startswith(p) for p in EXEMPT_PREFIXES):
return False
return path.startswith("docs/") or "/" not in path
# Git's OUTPUT FORMAT is configurable, and this script reads paths and line numbers out of that
# format. Three separate knobs were each demonstrated turning a real hit into `scanned 0 file(s)` —
# `core.quotePath` hiding non-ASCII paths, `diff.dstPrefix` rewriting the header, `color.diff=always`
# injecting ANSI escapes. Pinning them one at a time is refuting variants, not clearing the channel,
# so the channel is closed at both ends: the user's and the system's config files are taken out of
# the picture entirely (which also covers knobs nobody has thought of yet), and the handful that a
# REPO-local config could still set are pinned explicitly on the command line, where they win.
GIT_ENV_OVERRIDES = {
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_SYSTEM": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
}
# `core.quotePath=false` is witnessed by a test. The env overrides above are NOT, and cannot be: they
# exist for the knob nobody has named yet, which is exactly what three rounds of naming one knob at a
# time argued for. Every knob that IS named is pinned on the command line, where it also beats a
# repo-local config, and has a row in FORMAT_KNOBS in the test file.
GIT_CONFIG_PINS = ("-c", "core.quotePath=false")
def git(*args: str) -> tuple[int, str]:
"""Run git with its output format pinned. Returns (returncode, stdout) and never raises."""
try:
p = subprocess.run(
["git", *GIT_CONFIG_PINS, *args],
capture_output=True,
text=True,
errors="replace",
env={**os.environ, **GIT_ENV_OVERRIDES},
)
except OSError as exc: # git missing, or not a repo we can exec in
return 1, f"{exc}"
if p.returncode != 0:
sys.stderr.write(p.stderr)
return p.returncode, p.stdout
def scan_line(path: str, lineno: int, text: str, out: list[str]) -> None:
if PATTERNS.search(text):
detail = WARNING.format(path=path, line=lineno, text=text[:160])
out.append(f"::warning file={path}::{detail}")
def added_lines(diff: str):
"""Yield (path, lineno, text) for every ADDED line in a unified diff.
A line's meaning comes from HUNK STATE, not from its prefix: `+++ ` is a header only before the
first `@@` of a file, and inside a hunk it is content whose own text starts `++ `. That
distinction is the whole reason this is not a prefix match.
"""
path = None
in_hunk = False
lineno = 0
for raw in diff.split("\n"):
if raw.startswith("diff --git "):
path, in_hunk = None, False
elif raw.startswith("@@"):
m = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", raw)
if not m:
in_hunk = False
continue
in_hunk = True
lineno = int(m.group(1)) - 1
elif not in_hunk:
if raw.startswith("+++ "):
p = raw[4:]
# `/dev/null` on the new side means the file was DELETED. Stated honestly: this arm
# is DEFENSIVE, not load-bearing — a deletion contributes no `+` lines, so nothing is
# yielded for it either way, and removing this arm reddens no test. It is kept because
# `path` should never name a file the added lines do not belong to. A
# `--diff-filter=d` on the git call was removed rather than kept beside it: a second
# mechanism nobody can witness failing is how a duplicate guard hides its twin.
path = None if p == "/dev/null" else (p[2:] if p.startswith("b/") else p)
elif raw.startswith("+"):
lineno += 1
if path is not None:
yield path, lineno, raw[1:]
elif raw.startswith("-") or raw.startswith("\\"):
pass # a removed line, or the no-trailing-newline marker: neither advances the new file
else:
lineno += 1 # context (absent at -U0, but harmless and correct if -U grows)
def run_diff(base: str, out: list[str]) -> int:
rc, diff = git(
"diff",
"-U0",
# Pinned, not decorative: `diff.renames=false` in a developer's gitconfig turns a `git mv`
# into a whole-file add and re-flags every pre-existing line. Same channel as the prefixes.
"--find-renames",
# Pin the header shape the path is parsed out of. `diff.noprefix`, `diff.srcPrefix` and
# `diff.dstPrefix` each rewrite it from a developer's gitconfig, and `diff.external` replaces
# the output entirely — a configured prefix silently produced a scanned-0-files clean run.
"--src-prefix=a/",
"--dst-prefix=b/",
"--no-ext-diff",
"--no-color",
f"{base}...HEAD",
)
if rc != 0:
print(
f"doc-narrative: could not diff against '{base}' — SCANNED NOTHING. "
"This is reported rather than swallowed: a silent zero-file scan is indistinguishable "
"from a clean one, which is the failure `ci.required-job-step-execution-markers` exists for."
)
return -1
scanned = set()
for path, lineno, text in added_lines(diff):
if not is_scanned_path(path):
continue
scanned.add(path)
scan_line(path, lineno, text, out)
return len(scanned)
def run_all(out: list[str]) -> int:
# Population from `git ls-files`, never a filesystem walk — an untracked scratch file is not
# part of the corpus (#778).
rc, listing = git("ls-files", "-z", "--", "*.md")
if rc != 0:
print("doc-narrative: could not list tracked files — SCANNED NOTHING.")
return -1
scanned = 0
for path in listing.split("\0"):
if not path or not is_scanned_path(path):
continue
try:
with open(path, encoding="utf-8", errors="replace") as fh:
text = fh.read()
except OSError as exc:
# A tracked-but-deleted doc is an ordinary working state, not a reason to fail.
print(f"doc-narrative: skipped {path} ({exc.strerror}).")
continue
scanned += 1
# splitlines() keeps a final unterminated line, which `read`-per-line dropped.
for i, line in enumerate(text.splitlines(), start=1):
scan_line(path, i, line, out)
return scanned
def main(argv: list[str]) -> int:
mode = argv[1] if len(argv) > 1 else "--all"
out: list[str] = []
if mode == "--diff":
if len(argv) < 3 or not argv[2]:
print("doc-narrative: --diff needs a base ref — SCANNED NOTHING. (advisory; not a failure)")
return 0
scanned = run_diff(argv[2], out)
elif mode == "--all":
scanned = run_all(out)
else:
print(f"doc-narrative: unknown mode '{mode}'. usage: {argv[0]} [--all | --diff <base-ref>]")
return 0
for line in out:
print(line)
if scanned < 0:
return 0
print(
f"doc-narrative: scanned {scanned} file(s); {len(out)} advisory warning(s). "
"NON-BLOCKING — this check never fails a run."
)
return 0
if __name__ == "__main__":
try:
sys.exit(main(sys.argv))
# The never-fails constraint outranks a clean traceback, so this catch is deliberately blind.
except Exception as exc:
print(f"doc-narrative: internal error ({exc!r}) — SCANNED NOTHING. Advisory; not a failure.")
sys.exit(0)
+6 -8
View File
@@ -101,14 +101,12 @@ EOF
# message naming the variable; that is loud, instantly diagnosable, and the correct direction for a
# required check.
#
# EXCEPT ON A LANE WITH NO `container:` (ersatztv#767, extended by #772). `scan` and
# `toolchain-preflight` both run on `small` with no container, so RUNNER_TEMP is the shared host /tmp
# and the keying below is the ONLY thing separating runs, not defence in depth on top of a fresh
# filesystem. There the rerun residual DOES still exist: a single-job rerun that does not increment
# GITHUB_RUN_ATTEMPT would find the previous attempt's marker file. See the carve-out in
# `ci.required-job-step-execution-markers`; do not read the paragraph above as covering those jobs.
# The list is the property to keep current — a THIRD container-free job added without appearing here
# inherits a reassurance that was never checked for it.
# EXCEPT ON A LANE WITH NO `container:` (ersatztv#767). The `scan` job runs on `small` with no
# container, so RUNNER_TEMP is the shared host /tmp and the keying below is the ONLY thing separating
# runs, not defence in depth on top of a fresh filesystem. There the rerun residual DOES still exist:
# a single-job rerun that does not increment GITHUB_RUN_ATTEMPT would find the previous attempt's
# marker file. See the carve-out in `ci.required-job-step-execution-markers`; do not read the
# paragraph above as covering that job.
marker_path() {
local missing=""
[ -n "${GITHUB_JOB:-}" ] || missing="$missing GITHUB_JOB"
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env bash
# Preflight: does the PINNED CI toolchain image still exist in the registry? (ersatztv#772)
#
# WHY THIS EXISTS. `docker-build.yml` pins its five `container:` jobs to an immutable
# `ersatztv-ci:<sha>`. Between 2026-08-11 and 2026-08-13 that tag was deleted from the Gitea
# registry and every one of those jobs — including BOTH required contexts — died after 1-2s with
#
# Error response from daemon: failed to resolve reference "…/ersatztv-ci:<the pinned sha>": not found
#
# buried in each job's log. Nothing said "your toolchain image is gone", so the natural first
# reading was "my diff broke the build". This job says it in one line, in a job whose NAME says it.
#
# "Immutable" was taken to mean "will always exist", and those are different claims. The cause was
# an owner-level Gitea package cleanup rule (keep_count 15, remove_days 1, remove_pattern `.*`, and
# a keep_pattern no 7-hex sha can match), so a pinned tag is deleted once 15 newer versions of the
# package exist. The rule lives in the registry's repo — the durable fix is
# timothy/server-management#842 — and THIS script does not fix it. It converts a five-job pull
# failure into one actionable message, which is all a consumer of someone else's registry can do.
#
# WHY IT DOES NOT GATE THE CONTAINER JOBS with `needs:`. Serialising five jobs behind a checkout +
# one curl would tax every green run to speed up the rare red one, and the container jobs already
# fail fast (1-2s) when the pull fails. This runs in PARALLEL: the diagnosis is present the moment
# anyone looks, and the happy path pays nothing.
#
# UNKNOWN IS NOT A PASS, and this is where the first draft was wrong. It warned and exited 0 on
# every answer that was not 200 or 404, which makes "curl is missing from this runner", "the
# registry moved", and "DNS changed" all indistinguishable from a healthy pin — a job that is green
# forever having checked nothing, in a file whose header claims the opposite. Unknown answers are
# RETRIED (they are usually transient) and then FAIL. The message stays distinct from the deleted
# case: "could not verify" and "IS GONE" send an operator to different places.
#
# Env (all optional except the credential; the defaults are the live values):
# ETV_CI_REGISTRY registry host:port (default 192.168.1.95:3000)
# ETV_CI_IMAGE_REPO package path inside the registry (default timothy/ersatztv-ci)
# ETV_CI_WORKFLOW workflow file to read the pin from (default .gitea/workflows/docker-build.yml)
# ETV_CI_ATTEMPTS tries per pin before an unknown becomes a failure (default 3)
# ETV_CI_RETRY_SECONDS pause between those tries (default 5)
# ETV_REGISTRY_AUTH user:pass — REQUIRED; the registry rejects anonymous reads with 401
set -euo pipefail
registry="${ETV_CI_REGISTRY:-192.168.1.95:3000}"
image_repo="${ETV_CI_IMAGE_REPO:-timothy/ersatztv-ci}"
workflow="${ETV_CI_WORKFLOW:-.gitea/workflows/docker-build.yml}"
fail() { printf '::error::ci-toolchain-image-resolves: %s\n' "$*" >&2; exit 1; }
[ -f "$workflow" ] || fail "cannot read $workflow to find the toolchain pin"
# The same expression `pr-checks.yml::ci-image-pin` greps with, so the two cannot disagree about
# what "the pin" is. Note it is written so THIS line cannot match itself: the character after the
# colon here is `[`, which is not in [0-9a-f].
pins=$(grep -oE 'ersatztv-ci:[0-9a-f]+' "$workflow" | cut -d: -f2 | sort -u || true)
[ -n "$pins" ] || fail "no ersatztv-ci pin found in $workflow — if the grep pattern stopped matching, fix it here and in pr-checks.yml::ci-image-pin together"
# No credentials is NOT a pass. An unauthenticated read of this registry is a 401 for every tag,
# present or deleted, so a run without them would report "cannot tell" for a live pin and for a
# deleted one alike — the shape where a guard reports green having checked nothing.
#
# The EMPTY-halves check is the one that matters in CI and is easy to miss: an absent secret does
# not arrive here as an unset variable. `ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ ... }}`
# interpolates a missing secret to the empty string, so the job passes the non-empty string ":".
# Testing only the unset case would leave the production shape uncovered.
auth="${ETV_REGISTRY_AUTH:-}"
[ -n "$auth" ] || fail "ETV_REGISTRY_AUTH (user:pass) is unset, so the registry cannot be queried — this check refuses to report a pass it did not establish"
case "$auth" in
*:*) ;;
*) fail "ETV_REGISTRY_AUTH must be user:pass, got a value with no ':' — the registry cannot be queried and this check refuses to report a pass it did not establish" ;;
esac
[ -n "${auth%%:*}" ] && [ -n "${auth#*:}" ] \
|| fail "ETV_REGISTRY_AUTH has an empty half (user or password) — this is what an ABSENT REGISTRY_USER/REGISTRY_PASSWORD secret interpolates to, not a credential. Fix the secrets rather than reading an unauthenticated 401 as could-not-tell."
accept='application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
attempts="${ETV_CI_ATTEMPTS:-3}"
retry_seconds="${ETV_CI_RETRY_SECONDS:-5}"
rc=0
# One GET, returning "<code> <is-a-manifest>". The body is fetched rather than a HEAD sent, because
# HTTP 200 alone does not mean "the manifest is there": a proxy, a captive login page or an error
# document all answer 200 with a body that is not a manifest, and a check that reads only the status
# line reports those as "resolves". A manifest always carries `schemaVersion`, so the body is matched
# for it — with a shell `case`, so nothing depends on jq being installed and no pipeline can invert
# the result on a large body.
probe() {
local url="$1" resp code body
# `-u` puts the credential in argv, visible to `ps` for the length of the call — and this job has
# no `container:`, so that is the shared host. Kept because it is the shape every other curl caller
# in scripts/ already uses (`ci-detect-already-validated.sh`, `pr-changed-files.sh`,
# `select-queue.sh`, `issue-qualification-audit.sh`): fixing one site would leave the class intact
# and the codebase inconsistent. The class is tracked in ersatztv#821.
resp=$(curl -s -w '\n%{http_code}' -u "$auth" -H "Accept: $accept" "$url") || resp=""
[ -n "$resp" ] || { printf '000 no\n'; return 0; }
code=${resp##*$'\n'}
body=${resp%$'\n'*}
case "$body" in
*'"schemaVersion"'*) printf '%s yes\n' "$code" ;;
*) printf '%s no\n' "$code" ;;
esac
}
for pin in $pins; do
url="http://$registry/v2/$image_repo/manifests/$pin"
attempt=1
while : ; do
read -r code is_manifest <<EOF
$(probe "$url")
EOF
case "$code" in
200|404|401|403) break ;;
esac
# Only the unknown answers are retried: 200/404 are answers, and an auth failure will not cure
# itself. A transient registry is the common case for the rest, and absorbing it here is what
# lets the unknown be a FAILURE at the end rather than a warning nobody reads.
[ "$attempt" -lt "$attempts" ] || break
attempt=$((attempt + 1))
sleep "$retry_seconds"
done
case "$code" in
200)
if [ "$is_manifest" = "yes" ]; then
printf 'ci-toolchain-image-resolves: %s/%s:%s resolves (HTTP 200, manifest present)\n' "$registry" "$image_repo" "$pin"
else
printf '::error::ci-toolchain-image-resolves: %s/%s:%s answered HTTP 200 with a body that is not a manifest (no schemaVersion). Something is answering for the registry — a proxy, a login page, or an error document. The pin was NOT verified.\n' \
"$registry" "$image_repo" "$pin" >&2
rc=1
fi
;;
404)
# The one unambiguous answer, and the outage this exists for.
printf '::error::ci-toolchain-image-resolves: the pinned CI toolchain image %s/%s:%s IS GONE from the registry (HTTP 404). Every container: job in docker-build.yml will fail at image pull, including both required contexts, and NO diff caused it. Recovery does not need CI: rebuild that exact tag from the commit it names and push it — see docs/ci-cd.md -> "CI toolchain image" -> "When the pinned tag disappears". Root cause + the durable fix: timothy/server-management#842.\n' \
"$registry" "$image_repo" "$pin" >&2
rc=1
;;
401|403)
# `fail` rather than `rc=1`: unlike a 404, this says nothing about the pin, and it will say
# the same thing about every remaining one. Abandoning the loop keeps the log to one cause.
fail "the registry rejected these credentials (HTTP $code) for $registry/$image_repo:$pin, so the pin could not be checked. Fix REGISTRY_USER/REGISTRY_PASSWORD rather than reading this as a pass."
;;
*)
# NOT gone, and NOT a pass either. Deliberately worded apart from the 404 message: this sends
# an operator to the registry's health, not to a rebuild of a tag that may be sitting there.
printf '::error::ci-toolchain-image-resolves: could NOT VERIFY %s/%s:%s after %s attempt(s) (last answer: HTTP %s). This is not evidence the image is gone — it is evidence the check could not run, which fails rather than passing so the preflight cannot quietly become a no-op.\n' \
"$registry" "$image_repo" "$pin" "$attempts" "$code" >&2
rc=1
;;
esac
done
exit "$rc"
+7 -2
View File
@@ -19,7 +19,7 @@ from datetime import date
from pathlib import Path
from typing import NamedTuple
import scripts.decisions_lib as dl # (run with PYTHONPATH=. or as module)
import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as module)
SKIP_HEADINGS = dl.SKIP_HEADINGS # single source of truth
# `signals` is required alongside the lifecycle fields: the `**Signals:**` line (plus `key:`) is what
@@ -469,7 +469,12 @@ def _is_stripped_index(path: Path, archive_dir: Path, recs: list) -> bool:
file there is exempt only if it actually LOOKS like a stripped index: exactly one keyless
record whose heading is one of the known generated ones (`dl.SKIP_HEADINGS`).
"""
return path.parent == archive_dir and len(recs) == 1 and not recs[0].key and recs[0].heading in dl.SKIP_HEADINGS
return (
path.parent == archive_dir
and len(recs) == 1
and not recs[0].key
and recs[0].heading in dl.SKIP_HEADINGS
)
def record_wing_faults(records_dir: Path | None = None, archive_dir: Path | None = None) -> list[str]:
+6 -2
View File
@@ -62,7 +62,9 @@ def render(spec: dict) -> str:
"`scripts/update-openapi.sh`.*"
)
lines.append("")
lines.append(f"{len(spec.get('paths', {}))} endpoints, {len(operations)} operations.")
lines.append(
f"{len(spec.get('paths', {}))} endpoints, {len(operations)} operations."
)
lines.append("")
for tag in tags:
@@ -71,7 +73,9 @@ def render(spec: dict) -> str:
lines.append("| Method | Path | Operation | Summary |")
lines.append("|---|---|---|---|")
for op in sorted(grouped[tag], key=lambda o: (o["path"], o["method"])):
lines.append(f"| {op['method']} | `{op['path']}` | {op['operationId']} | {op['summary']} |")
lines.append(
f"| {op['method']} | `{op['path']}` | {op['operationId']} | {op['summary']} |"
)
lines.append("")
return "\n".join(lines).rstrip("\n") + "\n"
+15 -33
View File
@@ -37,7 +37,6 @@ explicitly NOT bounded; that is the accepted limit stated above.
from __future__ import annotations
import contextlib
import json
import os
import secrets
@@ -48,7 +47,6 @@ import sys
import threading
import time
def fail(msg: str, code: int) -> int:
print(f"FAIL: {msg}")
return code
@@ -62,19 +60,15 @@ def main() -> int:
i = 0
while i < len(argv):
if argv[i] == "--expect-server" and i + 1 < len(argv):
expect_server = argv[i + 1]
i += 2
expect_server = argv[i + 1]; i += 2
elif argv[i] == "--expect-tool" and i + 1 < len(argv):
expect_tools.append(argv[i + 1])
i += 2
expect_tools.append(argv[i + 1]); i += 2
else:
positional.append(argv[i])
i += 1
positional.append(argv[i]); i += 1
if len(positional) < 2:
return fail(
"usage: mcp_smoke.py <.mcp.json> <server> [timeout] [--expect-server NAME] [--expect-tool NAME]...", 2
)
return fail("usage: mcp_smoke.py <.mcp.json> <server> [timeout] "
"[--expect-server NAME] [--expect-tool NAME]...", 2)
cfg_path, server = positional[0], positional[1]
if len(positional) > 2:
try:
@@ -142,12 +136,8 @@ def main() -> int:
try:
proc = subprocess.Popen(
[resolved, *args],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
env=env,
cwd=workdir,
[resolved, *args], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, env=env, cwd=workdir,
start_new_session=True, # own process group, so children die with us
)
except OSError as exc:
@@ -232,8 +222,10 @@ def main() -> int:
os.killpg(pgid, sig)
except OSError:
break # no group members left
with contextlib.suppress(subprocess.TimeoutExpired):
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
time.sleep(0.2)
else:
for sig in (signal.SIGTERM, signal.SIGKILL):
@@ -253,20 +245,9 @@ def main() -> int:
id_tools = secrets.randbelow(2**31 - 1000) + 1000
while id_tools == id_init:
id_tools = secrets.randbelow(2**31 - 1000) + 1000
init = expect(
id_init,
{
"jsonrpc": "2.0",
"id": id_init,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "mcp-smoke", "version": "0"},
},
},
deadline,
)
init = expect(id_init, {"jsonrpc": "2.0", "id": id_init, "method": "initialize", "params": {
"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "mcp-smoke", "version": "0"}}}, deadline)
if init is None:
return fail(f"no 'initialize' response within {budget}s (server did not start)", 9)
if "error" in init:
@@ -282,7 +263,8 @@ def main() -> int:
return fail(f"wrong server: expected '{expect_server}', got '{actual}'", 13)
send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
listed = expect(id_tools, {"jsonrpc": "2.0", "id": id_tools, "method": "tools/list", "params": {}}, deadline)
listed = expect(id_tools, {"jsonrpc": "2.0", "id": id_tools,
"method": "tools/list", "params": {}}, deadline)
if listed is None:
return fail(f"no 'tools/list' response within {budget}s", 10)
lresult = listed.get("result")
+13 -44
View File
@@ -119,20 +119,21 @@ short=${sha:0:7}
base_ref=$(printf '%s' "$prjson" | jq -r '.base.ref // ""')
[ -n "$base_ref" ] || die "PR #$pr has no resolvable base branch (.base.ref) — refusing to post a verdict that cannot record what it was formed against"
# --- TOCTOU guard: refuse to green a head that stopped being head since we read it. -------------
# --- The comment (human-readable artifact + the hook's condition-(c) input). --------------------
# The verdict line MUST start the line: the hook anchors its parser to line-start precisely so a
# comment that merely QUOTES the template mid-sentence cannot self-approve a merge.
body="Review-verdict: $verdict @ $short"
[ -n "$note" ] && body="$body"$'\n\n'"$note"
comment_payload=$(jq -n --arg b "$body" '{body:$b}')
api_post "repos/$owner/$repo/issues/$pr/comments" "$comment_payload" >/dev/null \
|| die "failed to post the verdict comment on PR #$pr"
printf 'posted comment: Review-verdict: %s @ %s\n' "$verdict" "$short"
# --- TOCTOU guard: refuse to green a head that stopped being head while we were posting. --------
# Without this, a commit pushed between the head read above and the status write below would inherit
# a verdict written for its parent — reintroducing ersatztv#622 at a smaller time scale. We do NOT
# retry against the new head: the new commit is genuinely unreviewed, and silently re-targeting the
# verdict at it is exactly the failure this script exists to prevent.
#
# THE WINDOW THIS FENCES USED TO BE MUCH WIDER, and that is why the comment is now written AFTER the
# status rather than before it (ersatztv#792). Posting the comment first meant every refusal below
# left a PR carrying `Review-verdict: MERGEABLE @ <head>` with NO `review-verdict/h10` status — and
# the comment is not the gate. The half-state was read by an operator as consent that had not been
# granted. Ordering the two writes status-first makes the surviving half the SAFE half: a status
# with no comment leaves the hook at condition (c) with nothing to classify, which is an `ask`, not
# a grant. The refusals themselves are unchanged and must stay — see
# `release.verdict-writes-status-before-comment`.
# Fail CLOSED if the re-read itself fails. This used to be `sha_now=$(api_get ... | jq ...)`, where
# `set -e` + `pipefail` aborted the script on a failed GET — implicitly, but before any status was
# written. Folding the two reads into one variable with `|| true` would have swallowed that: both
@@ -141,36 +142,15 @@ base_ref=$(printf '%s' "$prjson" | jq -r '.base.ref // ""')
# introduced by the refactor, so the refusal is now explicit rather than a side effect of `set -e`.
prjson_now=$(api_get "repos/$owner/$repo/pulls/$pr") \
|| die "could not re-read PR #$pr to confirm the head and base had not moved while posting — no status was written. Re-run once Gitea is reachable."
# `[ -n "$x" ] && [ "$x" != "$want" ]` was a fail-OPEN on BOTH of the checks below (ersatztv#778).
# A 2xx body that merely LOST the field — `{"head":{},"base":{}}` — yields an empty value, so the
# `-n` conjunct is false, the comparison never runs, and the status is posted having confirmed
# NOTHING about either the head or the base. The re-read exists precisely to refuse when it cannot
# confirm, so a field it cannot read must die exactly like a field that moved. The transport failure
# one line up is already fatal; this closes the same hole one level down, which is where it keeps
# reappearing in this repo.
#
# WHICH LINE CARRIES THE SAFETY, stated because it is not the one it looks like: dropping the `-n`
# conjunct is the fix. The unconditional `!=` below already rejects an empty value, so the explicit
# `-z` arms are REDUNDANT for the safety property and exist only to give the operator an accurate
# message ("carried no head sha" rather than "moved to ''"). Disarming a `-z` arm alone therefore
# leaves the suite green — the two overlap, and a mutation proof aimed at it would be vacuous. The
# proof in `test_a_reread_that_LOSES_a_field_refuses_instead_of_posting` is taken against the real
# predecessor (the `-n` conjunct restored), which is what actually goes red.
sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""')
if [ -z "$sha_now" ]; then
die "re-read PR #$pr but its response carried no head sha, so it is UNPROVEN that the head is still $short — no status was written. Re-run once Gitea returns a well-formed PR body."
fi
if [ "$sha_now" != "$sha" ]; then
if [ -n "$sha_now" ] && [ "$sha_now" != "$sha" ]; then
die "head moved from $short to ${sha_now:0:7} while posting — that commit is UNREVIEWED, so no status was written. Re-review the new head and run this again."
fi
# The same TOCTOU window applies to the base (ersatztv#632): a retarget between the read above and
# the status write below would bind the verdict to a base that is no longer the PR's, and the head
# sha check would not notice because retargeting does not move the head.
base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""')
if [ -z "$base_now" ]; then
die "re-read PR #$pr but its response carried no base ref, so it is UNPROVEN that the base is still '$base_ref' — no status was written. Re-run once Gitea returns a well-formed PR body."
fi
if [ "$base_now" != "$base_ref" ]; then
if [ -n "$base_now" ] && [ "$base_now" != "$base_ref" ]; then
die "base branch changed from '$base_ref' to '$base_now' while posting — the diff you reviewed is not the diff this PR now merges, so no status was written. Re-review against the new base and run this again."
fi
@@ -186,17 +166,6 @@ api_post "repos/$owner/$repo/statuses/$sha" "$status_payload" >/dev/null \
|| die "failed to post the '$STATUS_CONTEXT' commit status on $short"
printf 'posted status: %s = %s on %s\n' "$STATUS_CONTEXT" "$state" "$short"
# --- The comment (human-readable artifact + the hook's condition-(c) input). --------------------
# Written LAST, after the gating status exists (ersatztv#792). The verdict line MUST start the line:
# the hook anchors its parser to line-start precisely so a comment that merely QUOTES the template
# mid-sentence cannot self-approve a merge.
body="Review-verdict: $verdict @ $short"
[ -n "$note" ] && body="$body"$'\n\n'"$note"
comment_payload=$(jq -n --arg b "$body" '{body:$b}')
api_post "repos/$owner/$repo/issues/$pr/comments" "$comment_payload" >/dev/null \
|| die "the '$STATUS_CONTEXT' status was written on $short, but the verdict COMMENT could not be posted. The merge gate needs both: it reads the comment for condition (c) and will ASK rather than auto-grant until one exists. Re-run this command once Gitea is reachable."
printf 'posted comment: Review-verdict: %s @ %s\n' "$verdict" "$short"
if [ "$state" = "failure" ]; then
printf '\nPR #%s stays BLOCKED: %s is failing on head %s.\n' "$pr" "$STATUS_CONTEXT" "$short"
else
+9 -9
View File
@@ -3,18 +3,18 @@
import argparse
import importlib
import sys
from uuid import UUID
import etv_client
from etv_client.api import ScriptedScheduleApi
def main():
parser = argparse.ArgumentParser(description="Run an ETV scripted schedule")
parser.add_argument("host", help="The ETV host (e.g., http://localhost:8409)")
parser.add_argument("build_id", type=UUID, help="The build ID for the playout")
parser.add_argument("mode", choices=["reset", "continue"], help="The playout build mode")
parser.add_argument("script_name", help="The name of the script module to use (e.g., one)")
parser.add_argument('host', help="The ETV host (e.g., http://localhost:8409)")
parser.add_argument('build_id', type=UUID, help="The build ID for the playout")
parser.add_argument('mode', choices=['reset', 'continue'], help="The playout build mode")
parser.add_argument('script_name', help="The name of the script module to use (e.g., one)")
known_args, unknown_args = parser.parse_known_args()
@@ -28,9 +28,9 @@ def main():
with etv_client.ApiClient(configuration) as api_client:
try:
define_content = script_module.define_content
reset_playout = script_module.reset_playout
build_playout = script_module.build_playout
define_content = getattr(script_module, 'define_content')
reset_playout = getattr(script_module, 'reset_playout')
build_playout = getattr(script_module, 'build_playout')
api_instance = ScriptedScheduleApi(api_client)
@@ -48,6 +48,6 @@ def main():
except AttributeError as e:
print(f"Error: the '{known_args.script_name}' script is missing a required function. {e}")
if __name__ == "__main__":
main()
+3 -1
View File
@@ -24,4 +24,6 @@ import pytest
@pytest.fixture(autouse=True)
def isolate_hook_fire_log(tmp_path_factory, monkeypatch):
monkeypatch.setenv("ETV_HOOK_FIRE_LOG_DIR", str(tmp_path_factory.mktemp("hook-fire-log")))
monkeypatch.setenv(
"ETV_HOOK_FIRE_LOG_DIR", str(tmp_path_factory.mktemp("hook-fire-log"))
)
-326
View File
@@ -1,326 +0,0 @@
"""Machinery for the clause-level mutation harness (ersatztv#790).
`docs/guard-inventory.md` grades each guard's proof `MUTATION`, `BEHAVIOUR-ONLY` or `NONE`, and
`MUTATION` means "a clause-level mutation was executed and this named test was witnessed red". A
witnessing performed once, by hand, decays the moment anyone edits the guard, and a grade nothing
re-checks can simply be wrong.
This module turns each such row from an assertion into a check: apply the guard's **declared**
clause mutation to an isolated copy of the repo and require the row's own named test to go RED.
WHAT IS DELIBERATELY NOT DONE. The mutation is declared per guard in `mutation_manifest.py`, never
inferred. A harness that guessed which clause of a 90-line hook is *the* guard would manufacture
exactly the confident-but-empty coverage this exists to prevent the reason
`testing.guard-ships-with-mutation-proof` rejects a generic runner. Guessing is also unnecessary:
most of the proof tests already name their clause in source (the BOM test's `= "efbbbf" ]; then`,
`UNSET_CLAUSE`, `prove-fix.sh`'s `if [ "$RC" -eq 0 ]; then`), and the manifest reuses that same
string rather than inventing a second one.
THE SANDBOX IS A REAL GIT REPOSITORY, not a directory of copied files. Several guards derive their
population from `git ls-files` and one drives `git worktree add`, so a plain copy would send them
down their degraded paths and every mutation would "redden" for a reason having nothing to do with
the clause. Its contents are the TRACKED files with WORKING-TREE content `git ls-files -s`, not a
filesystem walk (`testing.guard-derives-population-from-source`, and the reason #778's guard was red
on every developer checkout and green in CI: `.husky/_/` is generated by `npm ci` and untracked).
Two index entries are not regular files and are handled explicitly rather than by an exception:
the `.claude/skills/jellyfin` symlink is recreated as a symlink (it dangles outside `~/ersatztv`,
which is inherent to the cross-repo symlink pattern and not this harness's problem), and the
`ErsatzTV-macOS` gitlink is SKIPPED no guard reads the submodule, and materialising one would
cost a fetch per run.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
# Long enough that a slow shared runner is not mistaken for a hang, short enough that a genuinely
# stuck inner pytest fails the job rather than burning the whole CI budget. The full set of proof
# tests runs in ~7s locally.
PYTEST_TIMEOUT = 300
# Every git call here is local and confined to the sandbox; anything slower is stuck, not slow.
GIT_TIMEOUT = 120
# The pristine commit of each sandbox, held OUT OF THE REPOSITORY the proof tests drive. A ref inside
# it would be one more thing a proof can move: `git branch -f` fails on a checked-out branch, a
# global `init.defaultBranch` can collide with the name, and any `git update-ref`/`git checkout -B` a
# proof runs could retarget it. An object id kept here cannot be reached from inside the sandbox at
# all, and `git reset --hard <oid>` needs no ref to exist.
_BASELINES: dict[str, str] = {}
@dataclass(frozen=True)
class Mutation:
"""One declared clause mutation and the test that must notice it.
`guard` is the `Guard` column of `docs/guard-inventory.md` the thing being graded.
`target` is the file actually edited. They are usually the same; where they differ, `why` says
why, and `test_a_cross_file_mutation_states_why` requires it.
`clause` must occur EXACTLY ONCE in `target`: a mutation that lands on an unintended second site
proves something about a clause nobody declared.
`expect` is a substring the FAILING run's output must contain, and it is what stops exit code 1
from being the whole verdict. Pytest reports an ordinary exception the same way it reports a
failed assertion, so a mutation that merely CRASHES the proof test an emptied population
reaching an `IndexError`, a syntax error, an unrelated parametrisation would otherwise be
accepted as "the guard noticed". Naming the diagnostic the mutation is supposed to produce makes
each row's evidence specific: a red for a different reason fails here and has to be re-declared.
`granularity` is `CLAUSE` or `DETECTOR`, and it is the honest half of this harness. #790 opened
on the observation that neutering `pin_population_faults` wholesale is "coarser than disarming
one clause at a time coarse enough that a single surviving clause would not be noticed". That
is true, and it is also not always avoidable: a detector that accumulates faults from several
independent arms answers on ANY of them, so disarming one arm leaves its proof test green and
the only mutation that reddens is the whole detector. Recording which grade each guard actually
admits turns that from an unstated weakness into a measured property.
A `DETECTOR` entry does not merely SAY a finer mutation was tried; it carries that mutation in
`survived_clause`/`survived_replacement`, and `test_every_SURVIVING_clause_mutation_still_does`
re-runs it and requires the proof test to stay GREEN. The justification for the coarse grade is
therefore executed on every run, exactly like the grade it justifies a prose claim would decay
the same way the hand-run witnessing this whole harness replaces did.
"""
CLAUSE = "CLAUSE"
DETECTOR = "DETECTOR"
guard: str
target: str
clause: str
replacement: str
proof: str
granularity: str
expect: str
why: str
survived_clause: str = ""
survived_replacement: str = ""
@property
def node_id(self) -> str:
"""The inventory records proof refs as `file.py::test`; pytest wants a path."""
return f"scripts/tests/{self.proof}"
@dataclass(frozen=True)
class Verdict:
ok: bool
reason: str
def _clean_env(**extra: str) -> dict[str, str]:
"""The environment every subprocess here runs in, with git's ambient state REMOVED.
Exported `GIT_*` variables override `-C` and `cwd`. `GIT_DIR`, `GIT_WORK_TREE`,
`GIT_INDEX_FILE`, `GIT_COMMON_DIR` and `GIT_OBJECT_DIRECTORY` each redirect part of a repository;
`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` inject arbitrary settings, `core.worktree`
among them. Any of those reaching this module's `git init`/`add`/`commit`/`reset --hard` points
them at the REAL repository, and the "sandbox" would then write through the tree it exists to
stay out of. A git hook exports several of them, and this suite runs from one.
So this is a DENY-BY-DEFAULT boundary rather than a list of the variables anyone has thought of:
every `GIT_*` is dropped and only the identity this module sets itself is put back. Enumerating
the dangerous ones is how the first version of this function shipped covering three of them.
"""
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env.update(extra)
return env
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
# `-c` rather than the ambient configuration, because the sandbox must not inherit the
# developer's machine: a global `core.hooksPath` would fire this repo's husky hooks against a
# throwaway tree, and `commit.gpgsign` would block the commit on a signing key CI does not have —
# indefinitely, at a pinentry prompt, which no pytest timeout is watching.
return subprocess.run(
# `core.worktree` is pinned along with the rest: a proof that plants one in the sandbox's own
# config would otherwise redirect `reset --hard` and `clean -qffdx` at a tree outside it.
[
"git",
"-c",
"core.hooksPath=/dev/null",
"-c",
"commit.gpgsign=false",
"-c",
f"core.worktree={cwd}",
*args,
],
cwd=str(cwd),
check=True,
capture_output=True,
timeout=GIT_TIMEOUT,
env=_clean_env(
GIT_AUTHOR_NAME="mutation-harness",
GIT_AUTHOR_EMAIL="harness@example.invalid",
GIT_COMMITTER_NAME="mutation-harness",
GIT_COMMITTER_EMAIL="harness@example.invalid",
),
)
def build_sandbox(dest: Path, root: Path = REPO_ROOT) -> Path:
"""Materialise `root`'s tracked files at `dest` and make it a git repository."""
entries = subprocess.run(
["git", "-C", str(root), "ls-files", "-s", "-z"],
capture_output=True,
check=True,
timeout=GIT_TIMEOUT,
env=_clean_env(),
).stdout.decode()
copied = 0
for entry in entries.split("\0"):
if not entry:
continue
meta, path = entry.split("\t", 1)
mode = meta.split()[0]
if mode == "160000": # gitlink — see the module docstring
continue
src = root / path
dst = dest / path
dst.parent.mkdir(parents=True, exist_ok=True)
if src.is_symlink():
os.symlink(os.readlink(src), dst)
else:
shutil.copy2(src, dst)
copied += 1
if copied == 0:
raise RuntimeError(
"the sandbox population is EMPTY — `git ls-files` returned nothing, so every mutation "
"below would run against an empty tree and report success. Anti-vacuity, not paranoia."
)
_git(dest, "init", "-q", ".")
# `-f` because some tracked files are also gitignored; without it they would be dropped from the
# sandbox's index and a guard deriving its population from `git ls-files` would see less than the
# real repo does.
_git(dest, "add", "-A", "-f", ".")
_git(dest, "commit", "-qm", "mutation-harness sandbox")
_BASELINES[str(dest.resolve())] = _git(dest, "rev-parse", "HEAD").stdout.decode().strip()
return dest
def reset_sandbox(sandbox: Path) -> None:
"""Return the sandbox to its committed state between mutations.
The proof tests write into `tmp_path`, but a guard driven through its real entry point can leave
artifacts in the tree it is pointed at, and one mutation's residue reaching the next would make
the second result a function of the first's.
"""
# RESET TO THE RECORDED BASELINE COMMIT, never to bare HEAD. `git reset --hard` with no argument
# resets to whatever HEAD currently is — so a proof test that COMMITS inside the sandbox moves
# HEAD onto a commit containing the mutant, and every later "reset" would then faithfully restore
# it. The `finally` in `verify_mutation` puts the file back, but nothing would put HEAD back, and
# the contamination would surface as an unrelated red several mutations later.
#
# `-ff` rather than `-f` because a single `-f` refuses to delete a nested git repository, which is
# precisely what a proof driving `git init` or `git worktree add` into the sandbox leaves behind.
baseline = _BASELINES.get(str(sandbox.resolve()))
if baseline is None:
raise RuntimeError(
f"no recorded baseline for {sandbox} — it was not built by build_sandbox, so there is "
"nothing to reset TO and a reset here would pin whatever state the tree is in now"
)
_git(sandbox, "reset", "-q", "--hard", baseline)
_git(sandbox, "clean", "-qffdx")
def run_pytest(sandbox: Path, node_ids: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(
[
sys.executable,
"-m",
"pytest",
"-q",
"--no-header",
"--tb=short", # the assertion MESSAGE, which `expect` is matched against
"-p",
"no:cacheprovider", # keeps `git status` in the sandbox clean between mutations
*node_ids,
],
cwd=str(sandbox),
capture_output=True,
text=True,
env=_clean_env(PYTHONPATH="."),
timeout=PYTEST_TIMEOUT,
)
# Only exit code 1 means "a test ran and failed", and it is the only status accepted here. Everything
# else is rejected, which matters most for the two ways a proof ref goes stale — measured, because
# they are easy to get the wrong way round: with an explicit `file.py::function` node id, a missing
# FILE and a missing FUNCTION both exit 4 ("ERROR: not found"), while 5 needs a successful collection
# that selected nothing — a deselection. Reading either as a guard going red is how a harness reports
# coverage it does not have.
_PYTEST_RED_MEANINGS = {
0: "the named test still PASSED with the clause mutated, so the clause is not load-bearing for it",
2: "the inner pytest was interrupted",
3: "the inner pytest hit an internal error",
4: "the inner pytest could not resolve the node id — the proof ref names a file or a test that does not exist",
5: "the inner pytest collected successfully but selected NOTHING — the proof ref was deselected",
}
def verify_mutation(sandbox: Path, mutation: Mutation) -> Verdict:
"""Apply one declared mutation in `sandbox` and require its named test to go red.
The sandbox is left as it was found; callers still `reset_sandbox` between mutations because a
driven guard can dirty the tree in ways this function does not know about.
"""
target = sandbox / mutation.target
if not target.is_file():
return Verdict(False, f"the mutation target {mutation.target} does not exist in the sandbox")
original = target.read_text(encoding="utf-8")
occurrences = original.count(mutation.clause)
if occurrences != 1:
return Verdict(
False,
f"the declared clause occurs {occurrences} times in {mutation.target}, not once. "
"RETARGET it rather than loosening the match — a clause that has moved, or that now "
"matches a second site, means the recorded proof no longer points at what it claims to.",
)
mutated = original.replace(mutation.clause, mutation.replacement, 1)
if mutated == original:
return Verdict(False, "the replacement is identical to the clause, so nothing was mutated")
target.write_text(mutated, encoding="utf-8")
try:
result = run_pytest(sandbox, [mutation.node_id])
finally:
target.write_text(original, encoding="utf-8")
output = result.stdout + result.stderr
if result.returncode != 1:
meaning = _PYTEST_RED_MEANINGS.get(result.returncode, f"unexpected pytest exit code {result.returncode}")
return Verdict(False, f"{meaning}\n--- inner pytest output ---\n{output[-3000:]}")
# MATCHED AGAINST THE EXCEPTION OUTPUT ALONE, not the whole run. `--tb=short` echoes the failing
# SOURCE as well as the message, and every one of these assertions carries its message as a
# string literal a line or two above — so matching the full output would let a red at assertion A
# be certified by assertion B's text merely being on screen. Pytest prefixes exception lines with
# `E `, and that is the only part that reports what actually failed.
diagnostic = "\n".join(line[2:] for line in output.splitlines() if line.startswith("E "))
# This couples the harness to pytest's traceback FORMAT, and pytest is deliberately unpinned in
# `script-tests`. The coupling is fail-CLOSED: a release that stopped prefixing exception lines
# with `E ` would empty `diagnostic` and every row would fail here naming its own expectation,
# which is loud and instantly diagnosable. The alternative — matching the whole run — fails
# silently in the direction that certifies rows on the wrong red. Note the join: a multi-line
# assertion message arrives as several `E ` lines, so an expectation must not span a newline.
if mutation.expect not in diagnostic:
return Verdict(
False,
f"the named test went red, but NOT with the declared diagnostic {mutation.expect!r}. A red "
"for a reason other than the one this row records is not evidence about the clause — a "
"crash, a syntax error or an unrelated parametrisation all look like this. Re-declare "
f"`expect` once you know what the mutation now produces.\n--- inner pytest output ---\n"
f"{output[-3000:]}",
)
return Verdict(True, "the named test went red under the declared mutation, with the declared diagnostic")
-349
View File
@@ -1,349 +0,0 @@
"""The DECLARED clause mutations, one per `MUTATION`-graded row of `docs/guard-inventory.md`.
Data only. The machinery that applies these is `mutation_harness_lib.py`; the checks that keep this
file honest are `test_mutation_harness.py`.
Every entry is declared by hand and none is inferred, which is the whole design constraint from
ersatztv#790: "a harness that guesses which clause of a 90-line hook is *the* guard would manufacture
exactly the confident-but-empty coverage this is meant to prevent". Where a proof test already names
its own clause in source the BOM guard's `= "efbbbf" ]; then`, `UNSET_CLAUSE`, `prove-fix.sh`'s
`if [ "$RC" -eq 0 ]; then` the entry reuses THAT string rather than inventing a second one, so a
retarget in either place is caught by the other.
WHY AN ENTRY'S `target` MAY DIFFER FROM ITS `guard`. Some guards here ARE tests
(`scripts/tests/test_*.py`). Disarming such a guard makes it ABSENT rather than red, so
`testing.guard-ships-with-mutation-proof`'s checker-guard exception applies: the mutation goes into
the guarded ARTIFACT a deleted row, a planted phantom row and the check must report it. Mutating
a checker's own POPULATION instead is a trap that looks identical and is not: a shrunken population
makes every real row report as PHANTOM, so the proof reddens on a false positive while saying
nothing about the missing-row detection the row claims. `why` states per entry which shape applies
and why; no count is kept here, because a count of the entries below is a second copy of them.
"""
from __future__ import annotations
from scripts.tests.mutation_harness_lib import Mutation
CLAUSE = Mutation.CLAUSE
DETECTOR = Mutation.DETECTOR
MUTATIONS: tuple[Mutation, ...] = (
Mutation(
guard=".claude/hooks/posttooluse-worktree-marker.sh",
target=".claude/hooks/posttooluse-worktree-marker.sh",
clause='printf \'%s\\n\' "$me" > "$abs/.claude-worktree-owner" 2>/dev/null || true',
replacement="true",
proof="test_worktree_ownership_guard.py::test_MUTATION_a_marker_hook_that_stops_WRITING_makes_the_guard_go_quiet",
granularity=CLAUSE,
expect="the UNMUTATED pair did not deny",
why="The marker write is the hook's entire job; without it the guard has nothing to read and "
"fails open. The clause string is the one the proof test itself passes to its `_mutate` helper.",
),
Mutation(
guard=".claude/hooks/pretooluse-bom-guard.sh",
target=".claude/hooks/pretooluse-bom-guard.sh",
clause='= "efbbbf" ]; then',
replacement='= "deadbeef" ]; then',
proof="test_bom_guard_detection.py::test_DISARMING_the_BOM_comparison_stops_detection",
granularity=CLAUSE,
expect="the BOM comparison has moved",
why="The BOM comparison is the guard's only detection logic. Same clause the proof test names.",
),
Mutation(
guard=".claude/hooks/pretooluse-worktree-guard.sh",
target=".claude/hooks/pretooluse-worktree-guard.sh",
clause='marker="$root/.claude-worktree-owner"',
replacement='marker="$root/.claude-worktree-owner-NOTHING-WRITES-THIS"',
proof="test_worktree_ownership_guard.py::test_MUTATION_disarming_the_guards_MARKER_READ_stops_the_deny",
granularity=CLAUSE,
expect="the UNMUTATED guard did not deny",
why="The marker read is what the ownership decision hangs on. Same clause the proof test names.",
),
Mutation(
guard=".husky/pre-push",
target=".husky/pre-push",
clause="unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE",
replacement=": # clause removed by the mutation harness",
proof="test_prepush_unsets_git_env.py::test_MUTATION_DELETING_the_unset_lets_drift_through_silently",
granularity=CLAUSE,
expect="the UNMUTATED pre-push did not catch the drift",
why="Without the unset, every git call the pre-push chain makes is aimed at the repository git "
"exported the environment for, not the one being pushed. `UNSET_CLAUSE` in the proof test.",
),
Mutation(
guard="scripts/build_decisions_catalog.py",
target="scripts/build_decisions_catalog.py",
clause="want.strip() != have.strip()",
replacement="False",
proof="test_build_catalog_check_path.py::test_MUTATION_disarming_the_stale_comparison_stops_detection",
granularity=CLAUSE,
expect="the stale-detection clause has moved or been reworded",
why="`main()`'s only stale-detection logic, per the proof test's own docstring, which uses this "
"exact clause and this exact replacement.",
),
Mutation(
guard="scripts/ci-step-ran.sh",
target="scripts/ci-step-ran.sh",
clause='if ! grep -qxF "$key" "$marker" 2>/dev/null; then',
replacement="if false; then",
proof="test_ci_dropped_step_guard.py::test_dropping_ANY_single_step_FAILS_the_guard",
granularity=CLAUSE,
expect="never having executed",
why="The per-key membership test is what turns a dropped step into a red job; disarmed, every "
"expected key reads as present and the guard passes a run in which nothing executed.",
),
Mutation(
guard="scripts/decisions_validate.py",
target="scripts/decisions_validate.py",
clause="wing_faults=record_wing_faults() + yaml_faults,",
replacement="wing_faults=yaml_faults,",
proof="test_decisions_validate.py::test_main_actually_CALLS_the_wing_scan",
granularity=CLAUSE,
expect="a wing fault must fail the validator",
why="The wiring the proof test exists for: deleting this call left the whole suite green while "
"a real block-scalar record vanished under `decisions-validate: OK` (#609).",
),
Mutation(
guard="scripts/prove-fix.sh",
target="scripts/prove-fix.sh",
clause='if [ "$RC" -eq 0 ]; then',
replacement="if false; then",
proof="test_prove_fix.py::test_MUTATION_disarming_the_UNPROVEN_clause_reddens_the_refusal_test",
granularity=CLAUSE,
expect="the clause under mutation is gone",
why="The UNPROVEN branch: a named test that passes WITHOUT the fix must be refused. Same clause "
"the proof test names.",
),
Mutation(
guard="scripts/tests/test_ci_image_pin_population.py",
target="scripts/tests/test_ci_image_pin_population.py",
clause="for name in sorted(TOOLCHAIN_JOBS - set(pinned)):",
replacement="for name in sorted(set()):",
proof="test_ci_image_pin_population.py::test_a_single_job_losing_its_pin_is_DETECTED",
granularity=CLAUSE,
expect="the population check accepted a workflow in which a container job no longer runs",
why="The against-the-registry direction, and the one the other two clauses cannot cover: a job "
"that loses its `container:` block leaves `declared` and `pinned` equal, so only this "
"comparison notices it has moved to the bare runner. This is the clause #790 asked for "
"instead of neutering `pin_population_faults` wholesale.",
),
Mutation(
guard="scripts/tests/test_guard_populations_derive_from_git.py",
target="scripts/tests/test_guard_inventory.py",
clause="if ref in tracked:",
replacement="if (REPO_ROOT / ref).exists():",
proof="test_guard_populations_derive_from_git.py::test_no_derivation_admits_an_untracked_file",
granularity=CLAUSE,
expect="after git stopped tracking them",
why="THE GUARD IS A TEST, so the mutation goes into the guarded ARTIFACT — one of the "
"derivations it watches — rather than into the checker, per the checker-guard exception. "
"The clause is the exact defect this guard was written after: `derived_guard_files` read "
"its CALLERS from the index and then admitted the paths they name on `Path.exists()`, so a "
"tracked workflow naming a script that exists on one machine only entered the population "
"there, red on that checkout and green in CI (#778's third shape, found by cold review "
"inside #806 itself). Note what this mutation does NOT do: on a clean tree the mutated set "
"is identical, so `test_guard_inventory.py`'s own assertions stay green — only narrowing "
"the index, which is what the proof does, separates them. That is why the proof has to "
"remove EVERY member rather than sample one.",
),
Mutation(
guard="scripts/tests/test_optional_request_members.py",
target="scripts/tests/test_optional_request_members.py",
clause='"ArtworkContentTypeModel": (',
replacement='"ArtworkContentTypeModelRENAMED": (',
proof="test_optional_request_members.py::test_every_droppable_request_schema_has_a_stated_disposition",
granularity=CLAUSE,
expect="no disposition written down",
why="THE GUARD IS A TEST, so the mutation goes into the guarded ARTIFACT — here the "
"DISPOSITIONS registry the checker maintains, the same shape as the deleted "
"guard-inventory row below. Renaming the key rather than deleting the entry keeps the "
"module importable, so the red is a real set-equality failure and not an ImportError "
"reddening for the wrong reason. The rename fires BOTH directions — MISSING for the real "
"schema and PHANTOM for the renamed key — which is the correct behaviour and worth stating, "
"since `expect` names only the MISSING half. "
"`ArtworkContentTypeModel` is the right key to name: it is the exact schema #807's "
"hand-written table omitted, because `...Model` reads as a response model while it is in "
"fact reachable from the full-replace PUT /channels/{id}.",
),
Mutation(
guard="scripts/tests/test_guard_inventory.py",
target="docs/guard-inventory.md",
clause="| `.claude/hooks/decisions-guard.sh` | a commit | GUARD | NONE | — |\n",
replacement="",
proof="test_guard_inventory.py::test_the_inventory_covers_exactly_the_guards_that_exist",
granularity=CLAUSE,
expect="these guard files exist but have no row in guard-inventory.md",
why="THE GUARD IS A TEST, so the mutation goes into the guarded ARTIFACT rather than into the "
"checker — disarming a checker makes it absent, not red, and mutating its population instead "
"would only demonstrate a false POSITIVE (a shrunken population reports every real row as "
"phantom) while proving nothing about the missing-row detection the row claims. A deleted "
"row is the defect this guard exists to catch, and it is one of the mutations #774 witnessed "
"by hand.",
),
Mutation(
guard="scripts/tests/test_hook_fire_log.py",
target="scripts/tests/test_hook_fire_log.py",
clause=" return faults\n\n\ndef strip_instrumentation",
replacement=" return []\n\n\ndef strip_instrumentation",
proof="test_hook_fire_log.py::test_a_hook_that_LOSES_its_instrumentation_is_DETECTED",
granularity=DETECTOR,
expect="left the check GREEN. The check is not load-bearing",
why="NO CLAUSE-LEVEL MUTATION REDDENS THIS ONE, and that is a finding rather than a shortcut. "
"`instrumentation_faults` accumulates from four independent arms and a stripped hook trips "
"three of them at once (no sink source, no ETV_HOOK_FIRE_LIB assignment, no begin call), so "
"disarming any single arm leaves the other two answering and the proof test stays green. The "
"whole detector is therefore the smallest mutation this proof can witness — and the surviving "
"single-arm mutation below is re-run every time so that claim is checked, not recited.",
survived_clause=" if not _SOURCES_SINK.search(text):",
survived_replacement=" if False:",
),
Mutation(
guard="scripts/tests/test_remote_state_inventory.py",
target="docs/remote-state-inventory.md",
clause="| `scripts/post-review-verdict.sh` — commit-status write |",
replacement="| `scripts/DELETED-BY-THE-MUTATION-HARNESS.sh` — not a real path |",
proof="test_remote_state_inventory.py::test_every_in_scope_file_has_a_row_and_every_row_names_a_real_file",
granularity=CLAUSE,
expect="in scope but absent from docs/remote-state-inventory.md",
why="THE GUARD IS A TEST, so the mutation goes into the guarded ARTIFACT: a real executable's "
"row is renamed away, which is the MISSING-row defect the row's Blocks column claims — an "
"in-scope file with no classification. Two shapes were tried and rejected. Emptying the "
"guard's `git ls-files` derivation reddens the proof with an IndexError over an empty "
"population: a crash, not a detection. Planting a PHANTOM row reddens "
"`test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected` by contaminating "
"the fixture that test builds for itself, and proves the opposite direction from the one the "
"row claims. Renaming the row exercises both directions of the production set comparison at "
"once and is matched on the missing half.",
),
Mutation(
guard="scripts/tests/test_mutation_harness.py",
target="scripts/tests/mutation_harness_lib.py",
clause=" if mutation.expect not in diagnostic:",
replacement=" if False:",
proof="test_mutation_harness.py::test_MUTATION_disarming_the_DIAGNOSTIC_gate_accepts_a_red_for_the_wrong_reason",
granularity=CLAUSE,
expect="the UNMUTATED verdict already accepted it, so the mutant proves nothing",
why="The target is not the guard for a structural reason: the harness keeps its machinery in "
"`mutation_harness_lib.py` so a clause of it can be disarmed in an isolated copy at all. The "
"clause is the DIAGNOSTIC gate — the check that a failing proof failed with the diagnostic "
"its row declares. Disarmed, a red for any unrelated reason is certified as a guard doing "
"its job, which is the shape that made two rows in this very file measure nothing. The other "
"gate, the one requiring pytest exit code 1, carries its own proof in "
"`test_MUTATION_disarming_the_EXIT_STATUS_gate_accepts_a_run_that_NEVER_RAN_A_TEST`; the "
"inventory holds one ref per row, so this entry names the stronger of the two.",
),
Mutation(
guard="scripts/ci-toolchain-image-resolves.sh",
target="scripts/ci-toolchain-image-resolves.sh",
clause=" 404)",
replacement=" 4040)",
proof="test_ci_toolchain_image_resolves.py::test_MUTATION_a_deleted_tag_is_reported_as_a_failure",
granularity=CLAUSE,
expect="a deleted tag was not reported as GONE",
why="404 is the ONE answer that establishes the pinned toolchain image is gone; every other "
"code means the check could not run. Both fail the job, so the EXIT CODE does not separate "
"them and the mutation is caught by the DIAGNOSTIC instead: retargeting the arm sends the "
"real outage down the could-not-verify path, which sends an operator to the registry's "
"health rather than to the rebuild that fixes it.",
),
)
# ------------------------------------------------------------------------------------------------
# THE OTHER GUARDS — stated per guard, and compared for SET EQUALITY against the inventory
# ------------------------------------------------------------------------------------------------
#
# #790's third Done-when box asks that guards whose mutation cannot be declared be STATED. A reason
# keyed on the row's GRADE would be cheaper and is tautological: a new guard graded NONE inherits one
# automatically and nobody ever looks at that particular guard. A count of them is no better — it
# moves only on net change, so adding one undeclared guard while promoting another leaves it at 22.
#
# So this is keyed on the guard, and `test_every_GUARD_row_is_either_DECLARED_or_STATED_here` asserts
# set equality against the inventory's GUARD rows in both directions. That makes it the same kind of
# hand-maintained-but-machine-checked table as `docs/guard-inventory.md` itself: a new guard cannot
# arrive without someone writing a line here about why it carries no mutation, and a line cannot
# outlive the row it is about.
#
# WHAT `NONE` ACTUALLY MEANS, because the wording matters here: the row nominates no proof ref. It
# does NOT mean the guard is untested. `scripts/ci-prove-ban-detects.sh` is graded NONE and is driven
# end to end by `test_ci_release_path_scan_job.py`. Nominating a proof is a judgement about which
# test is THE proof, which is #775's scope; this file can only verify one afterwards.
UNDECLARED: dict[str, str] = {
# NO GROUPING. An earlier version sorted these into "driven through their deciding path" and
# "not driven at all", and the sort was wrong twice in successive review rounds — in both
# directions, over entries whose own text said the opposite. A category above a list is a second
# classification of the same facts, and it drifts the moment one entry's situation changes. Each
# entry states its own case instead.
#
# THE TWO THINGS THAT GO MISSING ARE DIFFERENT, and which one it is decides where the work goes.
# A guard may be DRIVEN — `test_hook_fire_log.py` executes most hooks through their real deciding
# branch, its matrix asserting instrumentation TRANSPARENCY (the wrapped and unwrapped runs
# agree), never that the decision is right or that a particular clause produced it — and still
# have no NOMINATED proof and no witnessed clause disarm. Nominating one is a judgement about
# which test is THE proof, which is #775's scope; this file can only verify one afterwards. A
# guard nothing executes at all needs the test first.
#
# `NONE` in the inventory means the row nominates no proof ref. It does NOT mean untested.
".claude/hooks/decisions-guard.sh": "Driven to a block and to a pass by test_hook_fire_log.py's "
"constructed cases, which assert transparency rather than the decision. No nominated proof, and "
"no clause disarmed.",
".claude/hooks/prepush-clean-worktree-check.sh": "Driven with a file both modified in the tree "
"and present in the pushed set, by test_hook_fire_log.py, for transparency. No nominated proof, "
"and no clause disarmed.",
".claude/hooks/prepush-donewhen.sh": "Driven against a stub Gitea by test_hook_fire_log.py, so "
"its real blocking path is reached — for transparency. No nominated proof, and no clause "
"disarmed.",
".claude/hooks/prepush-rebase-check.sh": "BEHAVIOUR-ONLY. A named test drives it and "
"test_hook_fire_log.py reaches its behind-origin block, but which clause carries that decision "
"has not been established by disarming one.",
".claude/hooks/pretooluse-agent-ram.sh": "Driven at 5% and 15% free memory through a stubbed "
"`memory_pressure`, by test_hook_fire_log.py, for transparency. No nominated proof, and no "
"clause disarmed.",
".claude/hooks/pretooluse-agent-model.sh": "Driven with and without a `model` in the payload by "
"test_hook_fire_log.py's matrix, for transparency. No nominated proof, and no clause disarmed.",
".claude/hooks/pretooluse-bash-guard.sh": "Driven with an ETV_UPDATE_GOLDENS command and a "
"harmless one by test_hook_fire_log.py's matrix, for transparency. No nominated proof, and no "
"clause disarmed.",
".claude/hooks/pretooluse-nav-guard.sh": "Driven with an `/iptv/` URL by test_hook_fire_log.py's "
"matrix, for transparency. No nominated proof, and no clause disarmed.",
".claude/hooks/design-sync-reminder.sh": "Driven by test_hook_fire_log.py, which gives it its "
"start/finish arguments and works around its self-throttle — for transparency, and never to the "
"one-shot branch that fires on the first Stop after a UI change and then allows. A proof has to "
"model that state transition rather than a single invocation.",
".claude/hooks/pretooluse-merge-consent.sh": "BEHAVIOUR-ONLY. Several suites execute it, but consent "
"is derived from several independent conditions, so which one a given red belongs to has to be "
"established before a clause can be named.",
".husky/commit-msg": "Outside the hook-fire population (that globs `.claude/hooks/*.sh`) and "
"executed by no test: the repositories the suite builds are fresh `git init`s that never install "
"husky, so the hook is absent rather than bypassed. A proof has to install or invoke it.",
".husky/pre-commit": "Runs lint-staged, the decisions guard, the root-PNG check and the format "
"gate. Only the decisions guard has an inventory row of its own — root-PNG and format are INLINE "
"here, so this one row is the whole classification of both, and neither has a proof. Executed by "
"no test, and nothing observes the dispatch itself.",
"scripts/check-kickoff-guard.sh": "Nothing drives it. A proof needs a tree carrying a revived "
"#237 reference, which is cheap and simply not written.",
"scripts/check-review-verdict.sh": "BEHAVIOUR-ONLY. Its named test feeds the real script an "
"input only one clause rejects, which proves it reacts, not that the clause is load-bearing.",
"scripts/ci-detect-already-validated.sh": "Blocks nothing directly — it feeds the skip gate. The "
"consequence a mutation would have to be observed through is a job that skips, which is visible "
"only in a workflow run.",
"scripts/ci-detect-docs-only.sh": "Same shape: it feeds the skip gate rather than blocking, so "
"its effect is visible only in a workflow run, not in this suite.",
"scripts/ci-prove-ban-detects.sh": "Driven end to end by test_ci_release_path_scan_job.py, and "
"it runs a mutation of its own at CI time. Grading it here needs a decision about what a second "
"mutation would add; the row nominates no ref today.",
"scripts/e2e-functional.sh": "Needs a running instance. Its clauses are HTTP contract "
"assertions, so a proof means booting the app — `scripts/e2e-local.sh`'s job, not this "
"harness's.",
"scripts/jq-preflight.sh": "BEHAVIOUR-ONLY. Its named test drives the real script below the "
"version floor; no clause has been disarmed to show the floor comparison is what refuses.",
"scripts/post-review-verdict.sh": "BEHAVIOUR-ONLY, and the most valuable upgrade on this list: "
"it writes the required `review-verdict/h10` status. Its re-read-and-compare has several arms "
"and naming one as THE clause needs the judgement #790 declines to make blind.",
"scripts/pr-changed-files.sh": "BEHAVIOUR-ONLY. Its named test feeds a short page to the real "
"enumeration; the pagination clause has not been disarmed.",
"scripts/tests/test_ci_release_path_scan_job.py": "A GUARD that is a test, so the mutation would "
"have to go into the guarded artifact — the release-path scan job in the workflow. Which "
"weakening of that job is THE defect it exists to catch has not been settled.",
}
+14 -27
View File
@@ -36,17 +36,9 @@ BOM = b"\xef\xbb\xbf"
def _git(cwd: Path, *args: str) -> None:
subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@e",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@e",
},
["git", *args], cwd=str(cwd), check=True, capture_output=True,
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@e",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@e"},
)
@@ -62,18 +54,12 @@ def _repo_with(tmp_path: Path, name: str, content: bytes) -> Path:
def _run(hook: Path, repo: Path, env: dict) -> tuple[int, bytes]:
payload = json.dumps(
{
"session_id": "s",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"cwd": str(repo),
"tool_input": {"command": "git commit -m x"},
}
)
p = subprocess.run(
["bash", str(hook)], input=payload.encode(), capture_output=True, cwd=str(repo), env=env, timeout=60
)
payload = json.dumps({
"session_id": "s", "hook_event_name": "PreToolUse", "tool_name": "Bash",
"cwd": str(repo), "tool_input": {"command": "git commit -m x"},
})
p = subprocess.run(["bash", str(hook)], input=payload.encode(), capture_output=True,
cwd=str(repo), env=env, timeout=60)
return p.returncode, p.stdout
@@ -119,9 +105,8 @@ def _path_without_xxd(tmp_path: Path) -> dict:
def test_the_fixture_really_stages_a_BOM(tmp_path):
repo = _repo_with(tmp_path, "Bad.cs", BOM + b"class A {}\n")
assert (repo / "Bad.cs").read_bytes()[:3] == BOM
staged = subprocess.run(
["git", "diff", "--name-only", "--cached"], cwd=str(repo), capture_output=True, text=True
).stdout.split()
staged = subprocess.run(["git", "diff", "--name-only", "--cached"], cwd=str(repo),
capture_output=True, text=True).stdout.split()
assert staged == ["Bad.cs"], f"nothing was staged, so the guard would have nothing to read: {staged}"
@@ -194,4 +179,6 @@ def test_DISARMING_the_BOM_comparison_stops_detection(tmp_path):
rc, out = _run(mutated, repo, dict(os.environ))
assert rc == 0
assert out == b"", f"disarming the BOM comparison did not stop detection, so it is not load-bearing: {out!r}"
assert out == b"", (
f"disarming the BOM comparison did not stop detection, so it is not load-bearing: {out!r}"
)
@@ -1,546 +0,0 @@
"""`scripts/build_decisions_catalog.py`'s `--check` path — the one CI actually runs.
`decisions-guard` (`.gitea/workflows/pr-checks.yml`, the "Active catalog in sync" step) runs
`python3 scripts/build_decisions_catalog.py --check`. `scripts/tests/test_build_catalog.py` covers
`render_catalog()` directly and never calls `main()` at all, so nothing there proves:
* that `main()`'s stale-detection comparison (`want.strip() != have.strip()`) is load-bearing —
a version that always agreed would pass every existing test;
* that the `if __name__ == "__main__": raise SystemExit(main())` wiring actually turns a stale
catalog into a non-zero process exit code, which is the only thing CI's `run:` step reads.
`testing.guard-ships-with-mutation-proof` (#775) is explicit that a guard is not tested because a
test *involving* it passes: it ships with a mutation proof disarm the guard's own clause, alone,
and a named test must go red. This file is that proof for the catalog-guard, plus the subprocess
proof that the `__main__` wiring is connected (the #751/#719 shape the decision record names: a
green `main()` behind dead wiring).
"""
from __future__ import annotations
import importlib.util
import re
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
import scripts.build_decisions_catalog as bc
import scripts.decisions_lib as dl
REPO_ROOT = Path(__file__).resolve().parents[2]
CATALOG_SCRIPT = REPO_ROOT / "scripts" / "build_decisions_catalog.py"
DECISIONS_LIB = REPO_ROOT / "scripts" / "decisions_lib.py"
SCRIPTS_INIT = REPO_ROOT / "scripts" / "__init__.py"
def _current_catalog_text() -> str:
return bc.render_catalog(dl.all_active_records())
# ------------------------------------------------------------------------------------------------
# ANTI-VACUITY — if the real corpus is empty, every assertion below passes for nothing.
# ------------------------------------------------------------------------------------------------
def test_the_real_corpus_is_non_empty_and_renders_a_real_catalog():
records = dl.all_active_records()
active = [r for r in records if r.status == "active" and r.key]
assert active, "no active decision records were parsed — every test below would be vacuous"
text = _current_catalog_text()
assert bc.BANNER in text, "render_catalog produced no banner — not a real catalog document"
assert f"`{active[0].key}`" in text, (
"render_catalog produced no row for a known active record — not a real catalog document"
)
# ------------------------------------------------------------------------------------------------
# `main(["--check"])` — the comparison CI reads
# ------------------------------------------------------------------------------------------------
def test_check_returns_0_when_OUTPUT_matches_render_catalog(tmp_path, monkeypatch):
fresh = _current_catalog_text()
output = tmp_path / "README.md"
output.write_text(fresh.rstrip("\n") + "\n", encoding="utf-8")
monkeypatch.setattr(bc, "OUTPUT", output)
assert bc.main(["--check"]) == 0
def test_check_returns_1_when_OUTPUT_is_stale(tmp_path, monkeypatch):
fresh = _current_catalog_text()
output = tmp_path / "README.md"
# Append a line: the on-disk file no longer matches what render_catalog would produce.
output.write_text(fresh.rstrip("\n") + "\nEXTRA STALE LINE\n", encoding="utf-8")
monkeypatch.setattr(bc, "OUTPUT", output)
assert bc.main(["--check"]) == 1
def test_generate_then_check_round_trips(tmp_path, monkeypatch):
"""The no-argument path WRITES the catalog, and a subsequent --check must then pass.
This pins the property CI depends on: generate and check agree. If they ever diverged, `main([])`
would produce a file that `main(["--check"])` immediately rejects a self-contradiction that
would make the generator useless for fixing the exact problem `--check` reports.
"""
output = tmp_path / "README.md"
assert not output.exists()
monkeypatch.setattr(bc, "OUTPUT", output)
assert bc.main([]) == 0
assert output.exists(), "main([]) with no --check must write OUTPUT"
written = output.read_text(encoding="utf-8")
assert written.strip() == _current_catalog_text().strip()
assert bc.main(["--check"]) == 0, "the file main([]) just wrote must satisfy main(['--check'])"
# ------------------------------------------------------------------------------------------------
# THE `__main__` WIRING — proof CI's subprocess invocation actually surfaces staleness
# ------------------------------------------------------------------------------------------------
def _seed_decisions_copy(root: Path) -> None:
"""Copy only what build_decisions_catalog.py + decisions_lib.py need to resolve a real corpus."""
(root / "scripts").mkdir(parents=True)
(root / "scripts" / "__init__.py").write_bytes(SCRIPTS_INIT.read_bytes())
(root / "scripts" / "build_decisions_catalog.py").write_bytes(CATALOG_SCRIPT.read_bytes())
(root / "scripts" / "decisions_lib.py").write_bytes(DECISIONS_LIB.read_bytes())
docs = root / "docs"
docs.mkdir()
(docs / "decisions.md").write_bytes((REPO_ROOT / "docs" / "decisions.md").read_bytes())
dst_decisions = docs / "decisions"
src_decisions = REPO_ROOT / "docs" / "decisions"
dst_decisions.mkdir()
for item in src_decisions.iterdir():
if item.is_dir():
_copy_tree(item, dst_decisions / item.name)
else:
(dst_decisions / item.name).write_bytes(item.read_bytes())
def _copy_tree(src: Path, dst: Path) -> None:
dst.mkdir(parents=True, exist_ok=True)
for item in src.rglob("*"):
rel = item.relative_to(src)
target = dst / rel
if item.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(item.read_bytes())
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "pr-checks.yml"
def _active_runs(workflow: Path | None = None) -> list[str]:
"""The `run` script of every step the `decisions-guard` job would ACTUALLY execute.
Parsed with `yaml.safe_load`, and returned WHOLE not split into lines. Both choices are
scar tissue.
Text-scanning for `run:` was round one, and cold review broke it three ways: a `run: |` block
scalar was invisible; a job or step switched off still read as wired; and `run:` inside
block-scalar *text* was extracted and executed. Round two parsed the YAML and matched a LINE
beginning with `PYTHONPATH=.` and review broke that too, with a heredoc:
run: |
cat <<'EOF' > /dev/null
PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
EOF
The matched line is heredoc DATA. The extractor reported the guard as running, and the proof
executed a command CI does not. Deciding which lines of a shell script are executed requires
parsing shell, and `fixing-a-parser-bug-introduces-the-next-one` is explicit that this repo has
lost that argument repeatedly a regex over shell is not a parser, and round four would find
round five.
So the line-level heuristic is WITHDRAWN. The whole `run` script is handed to `bash`, exactly as
the runner does. The heredoc above then runs, writes to `/dev/null`, checks nothing, and exits
0 so the stale-catalog case fails to redden and the proof reports the defect instead of
stepping around it. No shell parsing, and the ambiguous cases resolve by execution.
"""
doc = yaml.safe_load((workflow or WORKFLOW).read_text())
# The workflow's TRIGGERS, before its jobs. A `decisions-guard` job that is perfectly healthy
# gates nothing if the workflow stopped running on pull requests, and starting at `jobs:` cannot
# see that. Note `on` parses to the boolean True in YAML 1.1 (the Norway problem's cousin), so
# the key is looked up both ways rather than assumed.
triggers = (doc or {}).get("on", (doc or {}).get(True)) or {}
names = set(triggers) if isinstance(triggers, dict) else {triggers} if isinstance(triggers, str) else set(triggers)
assert "pull_request" in names, (
"pr-checks.yml no longer runs on `pull_request`, so NO gate in it — including the catalog "
f"guard — fires on a PR. Triggers found: {sorted(str(n) for n in names)}"
)
jobs = (doc or {}).get("jobs") or {}
job = jobs.get("decisions-guard")
assert job is not None, (
"no `decisions-guard` job in pr-checks.yml. Either it was renamed or it was removed — the "
f"second is the far more serious finding. Jobs present: {sorted(jobs)}"
)
assert not _disabled(job), (
"the `decisions-guard` job is disabled at the job level "
f"(if: {job.get('if')!r}, continue-on-error: {job.get('continue-on-error')!r}), so nothing "
"in it runs — including the catalog guard"
)
return [str(step["run"]) for step in (job.get("steps") or []) if step.get("run") and not _disabled(step)]
def _unwrap(value: str) -> str:
"""Strip an `${{ ... }}` expression wrapper, if present, and lowercase.
Written as a regex over the WHOLE value rather than `.strip("${{ }}")`, which strips a character
SET it would turn `"false}"` into `"false"` and reads as though it removed a wrapper it never
checked for.
"""
inner = value.strip()
m = re.fullmatch(r"\$\{\{(.*)\}\}", inner, flags=re.DOTALL)
if m:
inner = m.group(1)
return inner.strip().lower()
def _falsey(value) -> bool:
"""A literal false, however this workflow dialect spells it.
`if: false`, `if: "false"` and `if: ${{ false }}` all mean never. The middle and last are the
ones a text comparison misses; the last was a live false green `${{ false }}` is the ordinary
spelling in Actions-flavoured YAML, and it read as wired.
An expression that is merely falsy AT RUN TIME (`if: ${{ github.event_name == 'x' }}`) is not
decidable here and is deliberately not guessed at.
"""
if value is False:
return True
if not isinstance(value, str):
return False
return _unwrap(value) == "false"
def _truthy_literal(value) -> bool:
if value is True:
return True
if not isinstance(value, str):
return False
return _unwrap(value) == "true"
def _disabled(node: dict) -> bool:
"""A job or step that cannot fail the run: switched off, or allowed to fail.
`continue-on-error: true` is the subtle one the step still runs and still reports, but its
failure does not fail the job, so it is not a gate.
"""
return _falsey(node.get("if", True)) or _truthy_literal(node.get("continue-on-error", False))
def _ci_check_command(workflow: Path | None = None) -> list[str]:
"""The catalog step's script, DERIVED from the workflow and executed whole.
`testing.guard-derives-population-from-source`: a hand-copied command is a second copy of the
workflow that drifts silently, and this test's whole value is that it runs what CI runs.
"""
runs = _active_runs(workflow)
matches = [r for r in runs if "build_decisions_catalog.py" in r and "--check" in r]
assert matches, (
"the `decisions-guard` job has no ACTIVE step mentioning "
"`build_decisions_catalog.py --check`. The catalog guard has stopped running in CI — that "
f"is the finding, not this test's failure. Active step scripts in that job: {runs}"
)
assert len(matches) == 1, f"expected exactly one such step, found {matches}"
script = matches[0]
assert "${{" not in script, (
"the catalog step's script interpolates an Actions expression, which cannot be expanded "
f"outside the runner — this proof would be executing something else: {script!r}"
)
# Substitute the interpreter only where `python3` is a bare command word. A plain
# `str.replace` rewrites EVERY occurrence, including inside a path — `/usr/bin/python3` would
# become `/usr/bin/<venv>/bin/python3` and fail with ENOENT, a red blaming the catalog guard for
# something this line did.
return ["bash", "-c", re.sub(r"(?<![\w/])python3\b", sys.executable, script)]
def _proof_holds(workflow: Path, repo: Path) -> bool:
"""Does the whole guarantee hold — fresh corpus passes AND stale corpus fails?
The disablement cases below assert on THIS rather than on whether extraction raises, because
the ways a guard can stop gating do not all surface at the same place. Deletion and disablement
surface as a failed extraction; a heredoc or an `echo` surfaces only when the script is run and
reports success over a stale catalog. One predicate covers both.
"""
try:
cmd = _ci_check_command(workflow)
except AssertionError:
return False
readme = repo / "docs" / "decisions" / "README.md"
original = readme.read_text(encoding="utf-8")
try:
readme.write_text(original, encoding="utf-8")
fresh = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
if fresh.returncode != 0:
return False
readme.write_text(original + "\nSTALE INJECTED LINE\n", encoding="utf-8")
stale = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
return stale.returncode != 0
finally:
readme.write_text(original, encoding="utf-8")
_STEP = (
" - name: Active catalog in sync\n"
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
)
@pytest.mark.parametrize(
"label,mutate",
[
(
"the step is COMMENTED OUT",
lambda s: s.replace(
_STEP,
" # - name: Active catalog in sync\n"
" # run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
1,
),
),
("the step is DELETED", lambda s: s.replace(_STEP, "", 1)),
(
"the step is switched off with `if: false`",
lambda s: s.replace(
_STEP,
" - name: Active catalog in sync\n if: false\n"
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
1,
),
),
(
"the step is switched off with `if: ${{ false }}`",
lambda s: s.replace(
_STEP,
" - name: Active catalog in sync\n if: ${{ false }}\n"
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
1,
),
),
(
"the step is allowed to fail with `continue-on-error: true`",
lambda s: s.replace(
_STEP,
" - name: Active catalog in sync\n continue-on-error: true\n"
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
1,
),
),
(
"the whole JOB is switched off with `if: false`",
lambda s: s.replace(
" decisions-guard:\n name: decisions lifecycle\n runs-on: small\n"
" if: github.event_name == 'pull_request'\n",
" decisions-guard:\n name: decisions lifecycle\n runs-on: small\n if: false\n",
1,
),
),
(
"the command survives only as TEXT in another step's `echo`",
lambda s: s.replace(
_STEP,
" - name: Note\n run: |\n"
" echo we no longer run: PYTHONPATH=. python3 "
"scripts/build_decisions_catalog.py --check\n",
1,
),
),
(
"the WORKFLOW no longer runs on pull requests",
lambda s: s.replace("on:\n pull_request:", "on:\n workflow_dispatch:", 1),
),
(
"the command survives only as HEREDOC DATA",
lambda s: s.replace(
_STEP,
" - name: Note\n run: |\n"
" cat <<'EOF' > /dev/null\n"
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
" EOF\n",
1,
),
),
],
)
def test_a_guard_that_stopped_RUNNING_is_DETECTED(tmp_path, label, mutate):
"""Eight ways the catalog guard can stop gating, each of which must be caught.
Commenting out is one of them and was the only one the first version detected. The last two
leave the command in the file, parseable and even matchable as `echo` argument and as heredoc
data which is why the proof executes the step's whole script instead of a line lifted out of it.
"""
raw = WORKFLOW.read_text()
mutated = mutate(raw)
assert mutated != raw, f"the mutation for {label!r} matched nothing; RETARGET it"
alt = tmp_path / "pr-checks.yml"
alt.write_text(mutated)
repo = tmp_path / "repo-copy"
repo.mkdir()
_seed_decisions_copy(repo)
assert not _proof_holds(alt, repo), (
f"the catalog guard still reported as gating when {label}. CI would run nothing and this "
"file would report full coverage."
)
@pytest.mark.parametrize(
"label,replacement",
[
(
"block scalar",
" - name: Active catalog in sync\n run: |\n"
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
),
(
"backslash continuation",
" - name: Active catalog in sync\n run: |\n"
" PYTHONPATH=. python3 \\\n"
" scripts/build_decisions_catalog.py --check\n",
),
(
"a leading `set -euo pipefail`",
" - name: Active catalog in sync\n run: |\n"
" set -euo pipefail\n"
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
),
(
"wrapped in a shell block",
" - name: Active catalog in sync\n run: |\n"
" if true; then\n"
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
" fi\n",
),
],
)
def test_the_step_may_be_REFORMATTED_without_being_flagged(tmp_path, label, replacement):
"""The negative controls for the eight above: a legitimate rewrite must NOT be flagged.
`run: |` is idiomatic in this very job (the sibling "Validate decision lifecycle" step uses it),
and the text-scanning version reported that form as "the guard has stopped running in CI" a
false red whose message asserts a regression that has not happened. A detector that cannot tell
a reformat from a removal trains its readers to ignore it, which is the whole subject of #806.
A line-matching version had to be taught each of these shapes one at a time, and the
continuation case defeated two rounds of it. Executing the script gets all four for free: the
question "is the guard still gating" is answered by running it, not by recognising how it was
written.
"""
raw = WORKFLOW.read_text()
mutated = raw.replace(_STEP, replacement, 1)
assert mutated != raw, "retarget this reformatting; the step's text has changed"
alt = tmp_path / "pr-checks.yml"
alt.write_text(mutated)
repo = tmp_path / "repo-copy"
repo.mkdir()
_seed_decisions_copy(repo)
assert _proof_holds(alt, repo), f"a legitimate reformat ({label}) was reported as a removal"
def test_CLI_subprocess_exits_nonzero_on_a_stale_catalog(tmp_path):
"""The real CI invocation, as a subprocess, against a real corpus.
This is the `__main__` `SystemExit(main())` wiring proof: `main()` returning 1 is worthless if
the process still exits 0, and the workflow step reads nothing but the exit code. It is the
#751/#719 shape the decision record names — a green result behind wiring that is not connected.
The FRESH half is not optional decoration. Without it this test passes whenever the subprocess
dies for any reason at all an import error, a missing file in the copy, a syntax error none
of which is the guard detecting anything. `arbitrary-sample-gives-false-negatives` in reverse:
a non-zero exit is only evidence when the same harness is shown to exit zero on a clean corpus.
"""
repo = tmp_path / "repo-copy"
repo.mkdir()
_seed_decisions_copy(repo)
cmd = _ci_check_command()
fresh = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
assert fresh.returncode == 0, (
"the copied corpus does not even pass --check when untouched, so a non-zero exit below "
f"would be the harness failing rather than the guard firing: {fresh.stdout!r} {fresh.stderr!r}"
)
readme = repo / "docs" / "decisions" / "README.md"
readme.write_text(readme.read_text(encoding="utf-8") + "\nSTALE INJECTED LINE\n", encoding="utf-8")
stale = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
assert stale.returncode != 0, (
"the CLI wiring did not surface a stale catalog as a non-zero exit — CI would report "
f"green over a stale README.md: stdout={stale.stdout!r} stderr={stale.stderr!r}"
)
# ------------------------------------------------------------------------------------------------
# THE MUTATION PROOF — disarm `main()`'s stale-detection clause alone, --check must stop detecting
# ------------------------------------------------------------------------------------------------
def _load_mutated_module(tmp_path: Path, mutated_source: str):
mutated_path = tmp_path / "mutated_build_decisions_catalog.py"
mutated_path.write_text(mutated_source, encoding="utf-8")
spec = importlib.util.spec_from_file_location("mutated_build_decisions_catalog", mutated_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_MUTATION_disarming_the_stale_comparison_stops_detection(tmp_path, monkeypatch):
"""`testing.guard-ships-with-mutation-proof` (#775).
`main()`'s ONLY stale-detection logic is the clause `want.strip() != have.strip()`. Disarm that
clause alone replace it with the constant `False` in an isolated copy of the module, changing
nothing else and `--check` must stop reporting staleness. If it still returns 1, the deny is
coming from somewhere other than the clause the guard is supposed to hang on, and every test
above proves nothing about it.
"""
text = CATALOG_SCRIPT.read_text(encoding="utf-8")
clause = "want.strip() != have.strip()"
assert clause in text, (
"the stale-detection clause has moved or been reworded; RETARGET this mutation at its new "
"location rather than loosening the string match — a mutation that silently stops mutating "
"is the exact failure this file exists to catch"
)
mutated_source = text.replace(clause, "False", 1)
assert mutated_source != text and clause not in mutated_source, (
"the replacement did not change the source, so the mutant is the subject"
)
fresh = _current_catalog_text()
output = tmp_path / "README.md"
output.write_text(fresh.rstrip("\n") + "\nEXTRA STALE LINE\n", encoding="utf-8")
# POSITIVE CONTROL FIRST. Without this, the mutation assertion below would pass just as well if
# `main(["--check"])` never detected anything at all on this input — "the mutant is silent"
# proves nothing unless the real guard is first shown to be loud on the exact same input.
monkeypatch.setattr(bc, "OUTPUT", output)
assert bc.main(["--check"]) == 1, (
"the UNMUTATED guard did not detect the staleness on this input, so a silent mutant below "
"would prove nothing about the clause"
)
mutant = _load_mutated_module(tmp_path, mutated_source)
mutant.OUTPUT = output
assert mutant.main(["--check"]) == 0, (
"disarming `want.strip() != have.strip()` alone did not stop --check from reporting "
"staleness, so that clause is not what the guard's exit code hangs on"
)
-357
View File
@@ -1,357 +0,0 @@
"""Proofs for `scripts/check-doc-narrative.py` (ersatztv#784).
Every case below is a defect a cold review DEMONSTRATED in the first, shell implementation. They are
here because the never-fails invariant and the reported line numbers are both asserted in prose in
four places (the script header, the workflow comment, `docs/guard-inventory.md` and
`docs/remote-state-inventory.md`), and an invariant asserted only in prose is the shape this repo
keeps getting wrong.
The line-number cases all compare against the TRUTH computed from the file on disk, never against a
number written into the test a hand-written expectation is a second copy of the parser.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
SCRIPT = REPO / "scripts" / "check-doc-narrative.py"
NARRATIVE = "I initially thought otherwise"
HIT = re.compile(r"^::warning file=(?P<path>[^:]+)::(?P=path):(?P<line>\d+) ", re.MULTILINE)
def run(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, str(SCRIPT), *args], cwd=cwd, capture_output=True, text=True)
def hits(out: str) -> set[tuple[str, int]]:
return {(m.group("path"), int(m.group("line"))) for m in HIT.finditer(out)}
def truth(root: Path, rel: str) -> set[tuple[str, int]]:
"""Where the narrative marker ACTUALLY is, read back off disk."""
text = (root / rel).read_text(encoding="utf-8")
return {
(rel, i)
for i, line in enumerate(text.splitlines(), start=1)
if "initially thought" in line or "an earlier draft" in line.lower()
}
def git(repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
@pytest.fixture()
def repo(tmp_path: Path) -> Path:
r = tmp_path / "r"
(r / "docs").mkdir(parents=True)
git(r.parent, "init", "-q", "r")
git(r, "config", "user.email", "t@example.com")
git(r, "config", "user.name", "t")
(r / "docs" / "seed.md").write_text("seed\n", encoding="utf-8")
git(r, "add", "-A")
git(r, "commit", "-qm", "base")
return r
def commit(r: Path, msg: str = "c") -> None:
git(r, "add", "-A")
git(r, "commit", "-qm", msg)
# --- the never-fails invariant -------------------------------------------------------------------
@pytest.mark.parametrize(
("args", "expected"),
[
((), "scanned "),
(("--all",), "scanned "),
(("--diff",), "--diff needs a base ref"), # no base ref at all
(("--diff", ""), "--diff needs a base ref"), # `origin/` with base_ref unset
(("--diff", "no-such-ref-xyz"), "SCANNED NOTHING"), # an unresolvable ref
(("--diff", "origin/"), "SCANNED NOTHING"),
(("--nonsense",), "unknown mode"),
],
)
def test_every_argument_shape_exits_zero_HAVING_HANDLED_IT(repo: Path, args, expected: str) -> None:
"""The header, the workflow comment and both inventories state exit 0 absolutely.
The expected message is asserted alongside the exit code on purpose: the script ends with a bare
`except` that returns 0, so an exit-code-only assertion is satisfied by an unhandled crash and
would pass against a script that handles none of these shapes.
"""
p = run(repo, *args)
assert p.returncode == 0, f"args={args} exited {p.returncode}: {p.stderr}"
assert expected in p.stdout, f"args={args} exited 0 but did not HANDLE it: {p.stdout!r}"
assert "internal error" not in p.stdout, f"args={args} reached the last-resort handler: {p.stdout!r}"
def test_an_unknown_mode_prints_usage_and_scans_nothing(repo: Path) -> None:
"""Stated as the observable behaviour it actually pins. It is NOT a proof that the else-branch
cannot reach `run_all`: the branch returns before the warnings are printed, so a mutant calling
`run_all` there is silent and no black-box test can see it."""
(repo / "docs" / "u.md").write_text(f"x\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
p = run(repo, "--nonsense")
assert hits(p.stdout) == set()
assert "scanned" not in p.stdout
def test_a_DELETED_doc_is_not_reported_as_added_content(repo: Path) -> None:
"""A deletion contributes no added lines.
Stated exactly: this pins the BEHAVIOUR, and it is NOT a proof of the `+++ /dev/null` arm, which
is defensive removing that arm reddens nothing, because a deletion yields no `+` lines either
way. The script comment says the same. A docstring claiming a proof it does not have is worse
than no docstring: it is the thing a later reader trusts instead of re-checking."""
(repo / "docs" / "del.md").write_text(f"x\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
(repo / "docs" / "del.md").unlink()
commit(repo)
assert hits(run(repo, "--diff", "HEAD~1").stdout) == set()
# Every knob DEMONSTRATED to break the parse — not every knob that reshapes diff output, which is a
# universal nobody can check and which is the enumeration that failed three rounds running.
# (`diff.mnemonicPrefix` is a live example of one that reshapes the header and has no row: it emits
# `+++ w/f.md`. It is inert HERE for a reason worth stating exactly, because the obvious explanation
# is the wrong one — not because `--dst-prefix=b/` beats it, though it does, but because git only
# uses mnemonic prefixes when a diff side is the worktree or the index, and `run_diff` issues the
# three-dot `base...HEAD` form, where git emits plain `a/`…`b/` either way. Measured, not reasoned.)
# Three of these were each demonstrated turning
# a real hit into `scanned 0 file(s)`, one at a time, in three separate rounds — which is why the fix
# stopped pinning variants and removed the surface. The table is here so the next knob someone finds
# gets a row instead of a round.
FORMAT_KNOBS = [
("core.quotePath", "true"), # quotes non-ASCII paths out of the population
("diff.dstPrefix", "dst/"), # rewrites the header the path is read from
("diff.srcPrefix", "src/"),
("diff.noprefix", "true"),
("color.diff", "always"), # injects ANSI escapes into every line
("color.ui", "always"),
("diff.renames", "false"), # turns a `git mv` into a whole-file add
("diff.context", "9"), # a configured context must not beat the -U0 on the CLI
("diff.external", "/bin/echo"), # replaces the output wholesale
]
@pytest.mark.parametrize(("key", "value"), FORMAT_KNOBS)
def test_no_git_FORMAT_CONFIG_can_produce_a_false_clean(repo: Path, key: str, value: str) -> None:
"""A false clean is the worst outcome available to an advisory check: it is indistinguishable
from a real one and nobody looks twice."""
(repo / "docs" / "pfx.md").write_text("x\n", encoding="utf-8")
commit(repo)
(repo / "docs" / "pfx.md").write_text(f"x\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
git(repo, "config", key, value)
assert hits(run(repo, "--diff", "HEAD~1").stdout) == truth(repo, "docs/pfx.md"), f"{key}={value}"
# Only PYTHONIOENCODING is listed. `LC_ALL=C` was here too and was VACUOUS — PEP 540 UTF-8 mode
# means the pre-fix script passed it as well, so it read as a second witness where there was one.
@pytest.mark.parametrize("env_name,env_value", [("PYTHONIOENCODING", "ascii")])
def test_a_NON_UTF8_stdio_does_not_break_the_never_fails_invariant(repo: Path, env_name, env_value) -> None:
"""Both the summary line and the last-resort handler carry U+2014, so under ascii stdio the code
guaranteeing exit 0 was itself what raised."""
import os
env = dict(os.environ, **{env_name: env_value})
env.pop("PYTHONUTF8", None)
p = subprocess.run([sys.executable, str(SCRIPT), "--all"], cwd=repo, capture_output=True, text=True, env=env)
assert p.returncode == 0, f"{env_name}={env_value} exited {p.returncode}: {p.stderr}"
def test_a_tracked_but_DELETED_doc_does_not_kill_the_run(repo: Path) -> None:
"""`git ls-files` lists index entries; deleting a doc before committing is ordinary."""
(repo / "docs" / "gone.md").write_text("x\n", encoding="utf-8")
commit(repo)
(repo / "docs" / "gone.md").unlink()
p = run(repo, "--all")
assert p.returncode == 0
assert "skipped docs/gone.md" in p.stdout
def test_an_unresolvable_base_REPORTS_that_it_scanned_nothing(repo: Path) -> None:
"""A silent zero-file scan is indistinguishable from a clean one — the whole point of #751."""
p = run(repo, "--diff", "no-such-ref-xyz")
assert p.returncode == 0
assert "SCANNED NOTHING" in p.stdout
# The guard names the line this branch must NOT also print. It previously named "nothing to
# flag", a string that occurs nowhere in this script (it belongs to the sibling parity step), so
# it could never fail — a negative assertion over a literal that does not exist is not a guard.
assert "scanned 0 file(s)" not in p.stdout, "printed a clean-looking summary after scanning nothing"
def test_a_genuine_clean_scan_REPORTS_its_population(repo: Path) -> None:
p = run(repo, "--all")
assert "scanned 1 file(s); 0 advisory warning(s)" in p.stdout
# --- line numbers, against truth read off disk ---------------------------------------------------
def test_a_file_with_NO_trailing_newline_does_not_shift_later_lines(repo: Path) -> None:
"""`\\ No newline at end of file` is a marker, not a line of the new file."""
(repo / "docs" / "n.md").write_text("a\nb\nc", encoding="utf-8") # no trailing newline
commit(repo)
(repo / "docs" / "n.md").write_text(f"a\nb\nZ\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
assert hits(run(repo, "--diff", "HEAD~1").stdout) == truth(repo, "docs/n.md")
def test_an_added_line_whose_TEXT_starts_with_plus_plus_is_content_not_a_header(repo: Path) -> None:
"""Docs here embed diff output in fenced blocks, so `++ ` at column 0 is real."""
(repo / "docs" / "p.md").write_text("p\n", encoding="utf-8")
commit(repo)
(repo / "docs" / "p.md").write_text(f"p\n++ a fenced diff line\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
assert hits(run(repo, "--diff", "HEAD~1").stdout) == truth(repo, "docs/p.md")
def test_a_multi_hunk_file_numbers_every_hunk_from_its_own_header(repo: Path) -> None:
body = [f"l{i}" for i in range(1, 31)]
(repo / "docs" / "m.md").write_text("\n".join(body) + "\n", encoding="utf-8")
commit(repo)
body[4] = NARRATIVE # replace, early hunk
body.insert(15, NARRATIVE) # pure insert, middle hunk
body[-1] = NARRATIVE # replace, last hunk
(repo / "docs" / "m.md").write_text("\n".join(body) + "\n", encoding="utf-8")
commit(repo)
assert hits(run(repo, "--diff", "HEAD~1").stdout) == truth(repo, "docs/m.md")
def test_a_NON_ASCII_path_is_scanned_rather_than_silently_skipped(repo: Path) -> None:
"""`core.quotePath` quotes the path, and a quoted path matches no scope rule — it vanishes."""
(repo / "docs" / "café.md").write_text(f"x\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
assert hits(run(repo, "--all").stdout) == truth(repo, "docs/café.md")
assert hits(run(repo, "--diff", "HEAD~1").stdout) == truth(repo, "docs/café.md")
def test_a_final_UNTERMINATED_line_is_still_scanned_in_all_mode(repo: Path) -> None:
(repo / "docs" / "t.md").write_text(f"ok\n{NARRATIVE}", encoding="utf-8") # no trailing newline
commit(repo)
assert hits(run(repo, "--all").stdout) == truth(repo, "docs/t.md")
def test_a_RENAME_does_not_re_flag_the_whole_pre_existing_file(repo: Path) -> None:
"""Without rename detection a `git mv` reports every line of the file as newly added."""
(repo / "docs" / "r1.md").write_text("a\n" + f"{NARRATIVE}\n" + "b\n", encoding="utf-8")
commit(repo)
git(repo, "mv", "docs/r1.md", "docs/r2.md")
commit(repo)
assert hits(run(repo, "--diff", "HEAD~1").stdout) == set()
# `diff.renames` defaults to true, so the assertion above passes with or without the explicit
# `--find-renames`. Turning the config off is what makes that flag load-bearing and this test a
# real proof of it rather than a restatement of a git default.
git(repo, "config", "diff.renames", "false")
assert hits(run(repo, "--diff", "HEAD~1").stdout) == set(), "the --find-renames pin is not doing its job"
# --- the population ------------------------------------------------------------------------------
@pytest.mark.parametrize("mode", ["--all", "--diff"])
def test_decision_records_are_exempt_in_BOTH_modes(repo: Path, mode: str) -> None:
for rel in ("docs/decisions/records/x/y.md", "docs/decisions/archive/x/y.md"):
p = repo / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(f"x\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
out = run(repo, mode, "HEAD~1").stdout if mode == "--diff" else run(repo, mode).stdout
assert hits(out) == set()
def test_markdown_outside_the_stated_population_is_not_scanned(repo: Path) -> None:
"""The population is `docs/**/*.md` minus `docs/decisions/**`, plus root-level `*.md`. A skill
under `.claude/` is out of scope, and the record's `mechanics:` says so."""
(repo / ".claude" / "skills" / "s").mkdir(parents=True)
(repo / ".claude" / "skills" / "s" / "SKILL.md").write_text(f"{NARRATIVE}\n", encoding="utf-8")
(repo / "README.md").write_text(f"{NARRATIVE}\n", encoding="utf-8")
commit(repo)
assert hits(run(repo, "--all").stdout) == {("README.md", 1)}
# --- the detector actually detects ---------------------------------------------------------------
def test_a_MULTI_FILE_diff_scans_every_file_not_just_the_first(repo: Path) -> None:
"""Every real CI run is multi-file. Without the `in_hunk` reset on `diff --git`, the parser stays
inside the previous file's hunk and silently drops every file after the first."""
for name in ("a", "b", "c"):
(repo / "docs" / f"{name}.md").write_text("x\n", encoding="utf-8")
commit(repo)
for name in ("a", "b", "c"):
(repo / "docs" / f"{name}.md").write_text(f"x\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
p = run(repo, "--diff", "HEAD~1")
assert hits(p.stdout) == {(f"docs/{n}.md", 2) for n in ("a", "b", "c")}
assert "scanned 3 file(s); 3 advisory warning(s)" in p.stdout
def test_the_reported_POPULATION_COUNT_matches_the_files_actually_scanned(repo: Path) -> None:
"""The count is the observable that made every false clean in this file's history visible. A
mutant that never populated the scanned set reported `scanned 0 file(s); 3 warning(s)` green,
and self-contradictory."""
(repo / "docs" / "one.md").write_text("x\n", encoding="utf-8")
(repo / "docs" / "two.md").write_text("x\n", encoding="utf-8")
commit(repo)
(repo / "docs" / "one.md").write_text(f"x\n{NARRATIVE}\n", encoding="utf-8")
(repo / "docs" / "two.md").write_text("x\nharmless\n", encoding="utf-8")
commit(repo)
assert "scanned 2 file(s); 1 advisory warning(s)" in run(repo, "--diff", "HEAD~1").stdout
def test_a_NON_UTF8_LOCALE_does_not_silently_empty_the_scan(repo: Path) -> None:
"""`subprocess.run(errors="replace")` is the other half of the locale channel: without it a
UTF-8 doc under an ascii locale raises inside `git()`, the bare `except` catches it, and the
whole scan degrades to nothing while still exiting 0."""
import os
(repo / "docs" / "u8.md").write_text(f"héllo — em dash\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
env = dict(os.environ, LC_ALL="C", PYTHONUTF8="0", PYTHONIOENCODING="utf-8")
p = subprocess.run(
[sys.executable, str(SCRIPT), "--diff", "HEAD~1"],
cwd=repo,
capture_output=True,
text=True,
env=env,
)
assert p.returncode == 0, p.stderr
assert "internal error" not in p.stdout, p.stdout
assert hits(p.stdout) == {("docs/u8.md", 2)}
def test_the_pattern_is_case_insensitive(repo: Path) -> None:
(repo / "docs" / "case.md").write_text("AN EARLIER DRAFT of this said otherwise\n", encoding="utf-8")
commit(repo)
assert hits(run(repo, "--all").stdout) == {("docs/case.md", 1)}
def test_MUTATION_neutering_the_pattern_makes_a_known_hit_go_quiet(repo: Path, tmp_path: Path) -> None:
"""The clause-level disarm: with PATTERNS unable to match, a file that IS flagged stops being
flagged. Without this, every assertion above is satisfied by a detector that finds nothing."""
(repo / "docs" / "d.md").write_text(f"x\n{NARRATIVE}\n", encoding="utf-8")
commit(repo)
assert hits(run(repo, "--all").stdout) == truth(repo, "docs/d.md")
disarmed = tmp_path / "disarmed.py"
src = SCRIPT.read_text(encoding="utf-8")
marker = "PATTERNS = re.compile("
assert src.count(marker) == 1
disarmed.write_text(
src.replace(marker, 'PATTERNS = re.compile(r"(?!x)x" # disarmed\n or ', 1), encoding="utf-8"
)
p = subprocess.run([sys.executable, str(disarmed), "--all"], cwd=repo, capture_output=True, text=True)
assert p.returncode == 0, p.stderr
assert hits(p.stdout) == set(), "the disarmed detector still flagged something — the mutation did not take"
+34 -44
View File
@@ -30,12 +30,6 @@ import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
# ASSESSED FOR ersatztv#806: this file has NO filesystem-derived population. Its members come from
# the PARSED workflow (`_marked(job)` reads the marked steps out of `_DOC`), which is already an
# authoritative machine-readable source, so the index changes nothing here. Its known residual is at
# the other altitude — `MARKED_JOBS` is a hand-written mirror of the required contexts on `main`,
# a SCOPE rather than a population, and `testing.guard-derives-population-from-source` already
# carries it as this repo's canonical residual gap. #806 does not close it.
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "docker-build.yml"
SCRIPT = REPO_ROOT / "scripts" / "ci-step-ran.sh"
@@ -121,13 +115,13 @@ def _guard_buckets(job: str):
argv = _guard(job)["run"].split()
assert "--always" in argv and "--gated" in argv, argv
a, g = argv.index("--always"), argv.index("--gated")
return argv[a + 1 : g], argv[g + 1 :]
return argv[a + 1:g], argv[g + 1:]
# Mirrors the `if:` every gated step in these jobs carries. Compared as a normalised string rather
# than by parsing the expression: what matters is that a step's gating and the guard's bucketing are
# the SAME condition, and any rewrite of one that is not mirrored in the other should be loud.
SKIP_GATE = "steps.detect.outputs.docs_only!='true'&&steps.revalidate.outputs.skip!='true'"
SKIP_GATE = ("steps.detect.outputs.docs_only!='true'&&steps.revalidate.outputs.skip!='true'")
def _is_gated(step) -> bool:
@@ -217,14 +211,17 @@ def test_every_consequential_run_step_marks_itself_as_its_FIRST_act(job):
# prefix as a preceding command and reddens every correctly-written step.
lines = s["run"].splitlines()
at = next(i for i, ln in enumerate(lines) if _MARK.search(ln))
preceding = [ln.strip() for ln in lines[:at] if ln.strip() and not ln.strip().startswith("#")]
preceding = [
ln.strip() for ln in lines[:at]
if ln.strip() and not ln.strip().startswith("#")
]
if [ln for ln in preceding if not ln.startswith("set -")]:
late.append((s.get("name", "?"), preceding))
assert not missing, (
f"these run: steps of the REQUIRED job '{job}' do not record that they executed: {missing}. "
"A step the runner drops concludes success, so without a marker its non-execution takes the "
"whole required context green having done no work (ersatztv#756). Add "
'`"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark <key>` as the step\'s first line and '
'`\"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh\" mark <key>` as the step\'s first line and '
"the key to the guard step's --always/--gated list."
)
assert not late, (
@@ -360,10 +357,8 @@ def test_the_guards_OWN_body_cannot_be_dropped_by_the_mechanism_it_guards_agains
# a red here blocks every merge through the combined status, so brittleness is a real cost and
# not a free strictness win. Whitespace inside the delimiters is normalised for the same reason.
env = {k: re.sub(r"\s+", "", str(v)) for k, v in (guard.get("env") or {}).items()}
for name, want in (
("ETV_DOCS_ONLY", "${{steps.detect.outputs.docs_only}}"),
("ETV_REVALIDATE_SKIP", "${{steps.revalidate.outputs.skip}}"),
):
for name, want in (("ETV_DOCS_ONLY", "${{steps.detect.outputs.docs_only}}"),
("ETV_REVALIDATE_SKIP", "${{steps.revalidate.outputs.skip}}")):
assert env.get(name) == want, (
f"the '{job}' guard's env: has {name}={guard.get('env', {}).get(name)!r}, expected the "
f"output the gated steps' own `if:` reads ({want}). A typo here is SILENT rather than "
@@ -432,10 +427,12 @@ def _env(tmp_path, **extra):
def _run(script: str, env):
return subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env, capture_output=True, text=True)
return subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env,
capture_output=True, text=True)
@pytest.mark.parametrize("gate", GATE_VALUES_IN_THE_WILD, ids=["gate-false", "gate-empty", "gate-unset"])
@pytest.mark.parametrize("gate", GATE_VALUES_IN_THE_WILD,
ids=["gate-false", "gate-empty", "gate-unset"])
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_the_guard_PASSES_when_every_step_marked_itself(job, gate, tmp_path):
"""The positive control. Without it, a guard that always failed would satisfy every case below.
@@ -461,8 +458,7 @@ def test_the_guard_PASSES_when_every_step_marked_itself(job, gate, tmp_path):
# inverted provenance test would otherwise ship silently, and the operator reading this line to
# settle the promotion question would read it wrong.
assert f"Marker identity: job={job} run=424242 attempt=7 (from the runner)" in r.stdout, (
f"the guard misreported its marker identity: {r.stdout!r}"
)
f"the guard misreported its marker identity: {r.stdout!r}")
@pytest.mark.parametrize("job", MARKED_JOBS)
@@ -538,7 +534,8 @@ def test_an_EMPTY_gate_value_requires_the_gated_steps(job, tmp_path):
what is required.
"""
guard = _guard(job)["run"]
r = _run("\n".join(["set -e", guard]), _env(tmp_path, GITHUB_JOB=job, ETV_DOCS_ONLY="", ETV_REVALIDATE_SKIP=""))
r = _run("\n".join(["set -e", guard]),
_env(tmp_path, GITHUB_JOB=job, ETV_DOCS_ONLY="", ETV_REVALIDATE_SKIP=""))
assert r.returncode != 0
assert _guard_buckets(job)[1][-1] in (r.stdout + r.stderr), (
f"empty gate values were read as a skip, so the gated steps went unchecked: {r.stdout}"
@@ -574,7 +571,8 @@ def test_a_STALE_marker_from_another_run_cannot_satisfy_the_guard(tmp_path):
# ...nor may the OTHER job in the same run inherit them.
sibling = _env(tmp_path, GITHUB_JOB="migrations", GITHUB_RUN_ID="111", GITHUB_RUN_ATTEMPT="1")
assert _run(_guard("migrations")["run"], sibling).returncode != 0, (
"the two required jobs share one marker file, so one job's markers answer for the other's dropped steps"
"the two required jobs share one marker file, so one job's markers answer for the other's "
"dropped steps"
)
@@ -613,7 +611,8 @@ def test_a_key_is_matched_WHOLE_not_as_a_substring(tmp_path):
assert _run(f"{SCRIPT} mark web-build && {SCRIPT} mark web-test", env).returncode == 0
r = _run(f"{SCRIPT} assert --always build", env)
assert r.returncode != 0, (
"the key 'build' was satisfied by a marker for 'web-build' — a dropped `dotnet build` would pass unnoticed"
"the key 'build' was satisfied by a marker for 'web-build' — a dropped `dotnet build` would "
"pass unnoticed"
)
@@ -639,11 +638,9 @@ def test_a_degraded_run_IDENTITY_refuses_rather_than_sharing_a_marker_path(tmp_p
f"{r.stdout}{r.stderr}"
)
assert "cannot identify this run" in (r.stdout + r.stderr), (
f"refused, but without naming the cause: {r.stdout!r} {r.stderr!r}"
)
f"refused, but without naming the cause: {r.stdout!r} {r.stderr!r}")
assert not list(tmp_path.iterdir()), (
"a degraded-identity `mark` still created a marker file somewhere under RUNNER_TEMP"
)
"a degraded-identity `mark` still created a marker file somewhere under RUNNER_TEMP")
def test_the_marker_identity_is_REPORTED_on_stdout_every_run(tmp_path):
@@ -664,15 +661,12 @@ def test_the_marker_identity_is_REPORTED_on_stdout_every_run(tmp_path):
documented contract (the record's `mechanics:`), and a future reader is told to trust it.
"""
marks = [_mark_line(s) for s, _ in _marked("test")]
r = _run(
"\n".join(["set -e", *marks, _guard("test")["run"]]),
_env(tmp_path, GITHUB_RUN_ID="1916", GITHUB_RUN_ATTEMPT="4"),
)
r = _run("\n".join(["set -e", *marks, _guard("test")["run"]]),
_env(tmp_path, GITHUB_RUN_ID="1916", GITHUB_RUN_ATTEMPT="4"))
assert r.returncode == 0, r.stdout + r.stderr
assert "Marker identity: job=test run=1916 attempt=4 (from the runner)" in r.stdout, (
"the guard did not report the identity its marker path was actually keyed on, so a reader "
f"cannot audit the keying from a run log: {r.stdout!r}"
)
f"cannot audit the keying from a run log: {r.stdout!r}")
def test_a_skip_gate_that_empties_the_expected_set_REFUSES(tmp_path):
@@ -687,16 +681,15 @@ def test_a_skip_gate_that_empties_the_expected_set_REFUSES(tmp_path):
that it proved everything while proving nothing" is the failure this whole file exists to remove.
"""
r = _run(f"{SCRIPT} assert --always --gated foo", _env(tmp_path, ETV_DOCS_ONLY="true"))
assert r.returncode != 0, f"the guard passed with an empty post-gate expectation set: {r.stdout!r}"
assert r.returncode != 0, (
f"the guard passed with an empty post-gate expectation set: {r.stdout!r}")
assert "no expected keys" in (r.stdout + r.stderr).lower() or "NO expected keys" in r.stderr
@pytest.mark.parametrize(
"revalidate", ["true", "false", "", None], ids=lambda v: f"reval-{v if v is not None else 'unset'}"
)
@pytest.mark.parametrize(
"docs_only", ["true", "false", "", None], ids=lambda v: f"docs-{v if v is not None else 'unset'}"
)
@pytest.mark.parametrize("revalidate", ["true", "false", "", None],
ids=lambda v: f"reval-{v if v is not None else 'unset'}")
@pytest.mark.parametrize("docs_only", ["true", "false", "", None],
ids=lambda v: f"docs-{v if v is not None else 'unset'}")
def test_the_skip_gate_over_the_WHOLE_value_matrix(docs_only, revalidate, tmp_path):
"""Every combination of the two gate values, not just the diagonal — cold review's last finding.
@@ -720,14 +713,11 @@ def test_the_skip_gate_over_the_WHOLE_value_matrix(docs_only, revalidate, tmp_pa
job = "test"
always, gated = _guard_buckets(job)
marks = [_mark_line(s) for s, k in _marked(job) if k in always]
r = _run(
"\n".join(["set -e", *marks, _guard(job)["run"]]),
_env(tmp_path, ETV_DOCS_ONLY=docs_only, ETV_REVALIDATE_SKIP=revalidate),
)
r = _run("\n".join(["set -e", *marks, _guard(job)["run"]]),
_env(tmp_path, ETV_DOCS_ONLY=docs_only, ETV_REVALIDATE_SKIP=revalidate))
should_skip = docs_only == "true" or revalidate == "true"
assert (r.returncode == 0) is should_skip, (
f"with docs_only={docs_only!r} and revalidate={revalidate!r} the guard "
f"{'passed' if r.returncode == 0 else 'failed'}, expected it to "
f"{'skip the gated keys' if should_skip else 'require them'}. The gate must treat a value as "
"a skip if and only if it is exactly `true` in EITHER variable.\n" + r.stdout + r.stderr
)
"a skip if and only if it is exactly `true` in EITHER variable.\n" + r.stdout + r.stderr)
+10 -30
View File
@@ -38,22 +38,9 @@ from pathlib import Path
import pytest
import yaml
from scripts.tests.tracked_files import tracked_paths
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOWS_DIR = REPO_ROOT / ".gitea" / "workflows"
WORKFLOW = WORKFLOWS_DIR / "docker-build.yml"
# Resolved against the GIT INDEX rather than `Path.glob` (ersatztv#806), and `*.yaml` alongside
# `*.yml`: Gitea accepts both spellings, so a `.yaml` workflow was structurally invisible to the
# scope check below while reading as covered.
WORKFLOWS = (".gitea/workflows", ("*.yml", "*.yaml"))
def workflow_files() -> list[Path]:
"""THE WORKFLOW POPULATION, from the git index. Named rather than inline so the shared proof in
`test_guard_populations_derive_from_git.py` can assert it never admits an untracked file."""
return tracked_paths(*WORKFLOWS)
# The image repository, without the tag. Matched as a whole path rather than by the bare
# `ersatztv-ci` token so a job pointing at a LOOK-ALIKE registry (a personal fork, a typo'd host)
@@ -82,12 +69,10 @@ _DOC = yaml.safe_load(WORKFLOW.read_text())
# reviewable act; a job silently losing its container block is not.
TOOLCHAIN_JOBS = frozenset({"test", "migrations", "functional-e2e", "api-docs", "format"})
# `scan`, `build` and `toolchain-preflight` deliberately run on the bare runner: `scan` is
# `runs-on: small` and needs only python, `build` drives docker/buildx on the host, and
# `toolchain-preflight` exists to report that the pinned toolchain image is GONE — a job that
# consumed that image could not run to say so (ersatztv#772). Listed here so their ABSENCE above
# reads as a decision rather than an oversight.
BARE_RUNNER_JOBS = frozenset({"scan", "build", "toolchain-preflight"})
# `scan` and `build` deliberately run on the bare runner: `scan` is `runs-on: small` and needs only
# python, and `build` drives docker/buildx on the host. Listed here so their ABSENCE above reads as
# a decision rather than an oversight.
BARE_RUNNER_JOBS = frozenset({"scan", "build"})
def _jobs(doc) -> dict:
@@ -213,7 +198,8 @@ def test_the_registry_partitions_every_job_in_the_workflow():
)
assert not (TOOLCHAIN_JOBS & BARE_RUNNER_JOBS), "a job cannot be in both lists"
assert all_jobs == TOOLCHAIN_JOBS | BARE_RUNNER_JOBS, (
f"the registry names jobs that do not exist: {sorted((TOOLCHAIN_JOBS | BARE_RUNNER_JOBS) - all_jobs)}"
f"the registry names jobs that do not exist: "
f"{sorted((TOOLCHAIN_JOBS | BARE_RUNNER_JOBS) - all_jobs)}"
)
@@ -256,7 +242,8 @@ def test_a_single_job_losing_its_pin_is_DETECTED(doc):
empty list would satisfy the live assertion above and prove nothing which is how #621 and
#685 both shipped."""
assert pin_population_faults(doc), (
"the population check accepted a workflow in which a container job no longer runs the pinned toolchain image"
"the population check accepted a workflow in which a container job no longer runs the "
"pinned toolchain image"
)
@@ -280,7 +267,7 @@ def test_docker_build_is_the_ONLY_workflow_pinning_the_toolchain_image():
"""This file reads ONE workflow, which is itself a scope mirror needing its own check.
`WORKFLOW` hardcodes `docker-build.yml`, and the implicit claim that no other workflow uses
the toolchain image mirrors a machine-readable source (the tracked `.gitea/workflows/*.y*ml`)
the toolchain image mirrors a machine-readable source (the glob of `.gitea/workflows/*.yml`)
that nothing consulted. `renovate.yml` already declares a `container:` with a different image,
so the shape is live. A future workflow adopting `ersatztv-ci:` would acquire no pin-population
guard, no single-tag check and no partition, silently, while `pin_population_faults`'s own error
@@ -289,20 +276,13 @@ def test_docker_build_is_the_ONLY_workflow_pinning_the_toolchain_image():
Found by cold review, which correctly noted this file criticises `MARKED_JOBS` for exactly this
and then shipped the same shape without even the dated comment `MARKED_JOBS` carries.
The population comes from the GIT INDEX (ersatztv#806). A `Path.glob` here answered a question
about the machine rather than about the repo: an untracked scratch workflow left in
`.gitea/workflows/` would be parsed and could redden this test on one checkout while CI, which
never sees it, stayed green. The pattern set gained `*.yaml` in the same change Gitea accepts
both spellings, so a `.yaml` workflow adopting the toolchain image was invisible here while this
test read as covering every workflow.
Checked by PARSING each workflow's `container.image`, not by grepping the file. A text search
reports `ci-image.yml`, which names the image because it BUILDS and PUSHES it a producer, not
a consumer. Grepping would have made this test permanently red on a correct tree, which is the
fastest route to a correct guard being deleted.
"""
others = []
for p in workflow_files():
for p in sorted(WORKFLOWS_DIR.glob("*.yml")):
if p.name == WORKFLOW.name:
continue
doc = yaml.safe_load(p.read_text()) or {}
+28 -72
View File
@@ -77,15 +77,6 @@ from pathlib import Path
import pytest
import yaml
# Imports the shared index derivation to build a HERMETIC fixture copy, not to derive a guard
# population — see `_repo_copy`. Recorded as such in POPULATION_EXEMPT in
# `test_guard_populations_derive_from_git.py`; the exemption lives there, not here, because a marker
# a file grants itself is a kill switch any prose mention can trip.
from scripts.tests import tracked_files
# Both spellings, matching what the converted guards consider the workflow set.
WORKFLOWS = (".gitea/workflows", ("*.yml", "*.yaml"))
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "docker-build.yml"
SCRIPT = REPO_ROOT / "scripts" / "ci-step-ran.sh"
@@ -278,49 +269,12 @@ def _scan_body_and_env():
def _repo_copy(tmp_path: Path) -> Path:
"""A minimal executable copy of the repo: the tracked workflows plus the tracked `scripts/`.
ASSESSED FOR ersatztv#806. This is NOT a completeness guard — it is a fixture assembling a
harness, and no assertion in this file is about which files it found; the probes assert what the
scan command DOES to the copy. It takes its file LIST from the index anyway, for hermeticity
rather than completeness: `shutil.copytree` copied whatever was on disk, so untracked files and
`scripts/__pycache__` entered a tree whose behaviour the probes then measure.
WHAT THAT DOES AND DOES NOT BUY, stated exactly, because a fixture described as hermetic stops
being questioned. The list comes from the index; the CONTENT comes from the working tree, so an
unstaged edit to a tracked `scripts/**` file is still copied in. Making the content hermetic too
would need `git show`/`git archive` and would mean the probes stop testing the tree under edit,
which is the wrong trade for a test whose job is to catch a disarm in that tree.
An extra WORKFLOW in the copy is inert, but check what the step runs before relying on that:
`docker-build.yml:723` runs TWO files, `test_ci_dropped_step_guard.py` AND this one, and both
parse only `docker-build.yml`.
THE COPY IS NOT A GIT REPOSITORY, and that is the constraint to know before touching either file
that step runs. Neither may derive a population through `scripts/tests/tracked_files.py`:
`git ls-files` inside the copy fails, and the release-path scan step fails with it. The copying
happens HERE, in the real repo, which is why this fixture may use the index while its subjects
may not. `test_ci_dropped_step_guard.py`'s `MARKED_JOBS` is explicitly left open as a residual
gap, so a future session is invited to edit exactly that file this paragraph is what stands
between that edit and a broken release gate.
"""
"""A minimal executable copy of the repo: the workflow plus all of scripts/."""
dst = tmp_path / "repo"
(dst / ".gitea" / "workflows").mkdir(parents=True)
for wf in tracked_files.tracked_paths(*WORKFLOWS):
for wf in (REPO_ROOT / ".gitea" / "workflows").glob("*.yml"):
shutil.copy2(wf, dst / ".gitea" / "workflows" / wf.name)
for rel in tracked_files._git_ls_files():
if not rel.startswith("scripts/"):
continue
source = REPO_ROOT / rel
# Diagnosed, not raised as a bare FileNotFoundError one line after using the module whose
# whole point is reporting this case comprehensibly.
assert source.is_file(), (
f"git tracks {rel} but there is no file there, so the harness copy would be incomplete "
"and the probes below would measure a tree that is missing part of the thing under test."
)
target = dst / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
shutil.copytree(REPO_ROOT / "scripts", dst / "scripts")
return dst
@@ -374,7 +328,8 @@ def _run_the_real_scan_body(repo: Path, tmp_path: Path):
(repo / _NONCE_FILE).write_text(nonce)
env[_FENCE] = nonce
(tmp_path / "runner").mkdir(exist_ok=True)
return subprocess.run(["bash", "-c", body], cwd=repo, env=env, capture_output=True, text=True)
return subprocess.run(["bash", "-c", body], cwd=repo, env=env,
capture_output=True, text=True)
@_nested
@@ -403,7 +358,7 @@ def test_the_scan_step_REALLY_FAILS_on_a_poisoned_workflow(tmp_path):
text = wf.read_text()
anchor = ' IMG="${IMAGE}:${SMOKE_SHORT_SHA}"'
assert anchor in text, "anchor for the poison is gone — rewrite this control"
wf.write_text(text.replace(anchor, " # ${{ steps.meta.outputs.short }}\n" + anchor, 1))
wf.write_text(text.replace(anchor, ' # ${{ steps.meta.outputs.short }}\n' + anchor, 1))
res = _run_the_real_scan_body(repo, tmp_path)
assert res.returncode != 0, (
@@ -448,10 +403,8 @@ def test_the_PROOF_SCRIPT_itself_refuses_when_the_ban_is_deselected(tmp_path):
)
res = subprocess.run(
["bash", str(repo / "scripts" / "ci-prove-ban-detects.sh")],
cwd=repo,
env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True,
text=True,
cwd=repo, env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True, text=True,
)
assert res.returncode != 0, (
"ci-prove-ban-detects.sh vouched for the gate while the ban test was deselected at the "
@@ -493,16 +446,19 @@ def test_the_PROOF_SCRIPT_refuses_when_the_WRONG_test_fails(tmp_path):
# landing on the "not enforcing" branch instead of the one under test. (First draft of this test
# did exactly that and was red for the wrong reason.)
ban = repo / BAN_TEST_FILE
ban.write_text(ban.read_text() + "\n\ndef test_an_unrelated_failure_for_this_probe():\n assert False\n")
ban.write_text(
ban.read_text()
+ "\n\ndef test_an_unrelated_failure_for_this_probe():\n assert False\n"
)
res = subprocess.run(
["bash", str(repo / "scripts" / "ci-prove-ban-detects.sh")],
cwd=repo,
env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True,
text=True,
cwd=repo, env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True, text=True,
)
combined = res.stdout + res.stderr
assert res.returncode != 0, f"the script read an unrelated test's failure as proof.\n{combined}"
assert res.returncode != 0, (
f"the script read an unrelated test's failure as proof.\n{combined}"
)
# THE SPECIFIC branch. This scenario is built to land on "wrong test failed" (exit 1, no `[build]`
# failure); accepting "could not prove anything" too would let it drift onto the exit-5 branch and
# silently cover a branch it was not written for, while still looking green.
@@ -518,17 +474,15 @@ def test_the_PROOF_SCRIPT_passes_on_a_clean_tree(tmp_path):
repo = _repo_copy(tmp_path)
res = subprocess.run(
["bash", str(repo / "scripts" / "ci-prove-ban-detects.sh")],
cwd=repo,
env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True,
text=True,
cwd=repo, env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True, text=True,
)
assert res.returncode == 0, (
f"ci-prove-ban-detects.sh failed on a clean tree.\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}"
)
assert (repo / ".gitea" / "workflows" / "docker-build.yml").read_text() == (WORKFLOW.read_text()), (
"the script did not restore the workflow file it poisoned"
)
assert (repo / ".gitea" / "workflows" / "docker-build.yml").read_text() == (
WORKFLOW.read_text()
), "the script did not restore the workflow file it poisoned"
def test_the_pytest_invocation_cannot_DESELECT_or_swallow_its_result():
@@ -540,7 +494,7 @@ def test_the_pytest_invocation_cannot_DESELECT_or_swallow_its_result():
# Only the tokens AFTER `pytest` are pytest's own arguments. Checking the whole line would flag
# the `-m` in `python3 -m pytest`, which is how the interpreter is invoked — a false positive
# that would make this test red on the correct command.
args = tokens[tokens.index("pytest") + 1 :]
args = tokens[tokens.index("pytest") + 1:]
banned = {"-k", "-m", "--deselect", "--ignore", "--collect-only", "--co"}
assert not (banned & set(args)), f"pytest invocation may deselect tests: {line!r}"
for op in ("||", "&&", ";", "|"):
@@ -573,7 +527,8 @@ def test_no_step_in_the_scan_job_is_advisory():
@pytest.mark.parametrize(
"step_name",
[s.get("name", "?") for s in yaml.safe_load(WORKFLOW.read_text())["jobs"][JOB]["steps"] if s.get("run")],
[s.get("name", "?") for s in yaml.safe_load(WORKFLOW.read_text())["jobs"][JOB]["steps"]
if s.get("run")],
)
def test_every_run_body_in_the_scan_job_is_delimiter_free(step_name):
"""The guard must not be vulnerable to the defect it guards against.
@@ -606,7 +561,7 @@ def test_the_guard_expectations_match_the_markers_exactly():
restated here, so adding a step without a marker is a red."""
argv = _guard()["run"].split()
assert "--always" in argv, argv
always = argv[argv.index("--always") + 1 :]
always = argv[argv.index("--always") + 1:]
assert "--gated" not in argv, "every step in this job is unconditional; there is nothing to gate"
assert sorted(always) == sorted(k for _, k in _marked())
@@ -646,7 +601,8 @@ def _env(tmp_path, **extra):
def _run(script: str, env):
return subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env, capture_output=True, text=True)
return subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env,
capture_output=True, text=True)
def test_the_guard_PASSES_when_every_step_ran(tmp_path):
@@ -1,303 +0,0 @@
"""Tests for `scripts/ci-toolchain-image-resolves.sh` (ersatztv#772).
The script answers one question does the tag `docker-build.yml` pins still exist? and the whole
value is in *which answers it refuses to round off*. A registry read has three outcomes, not two:
present, gone, and could-not-tell. Collapsing the third into either of the others is how a preflight
becomes decoration, so each is driven here through the real entry point with a stubbed `curl`.
`test_MUTATION_a_deleted_tag_is_reported_as_a_failure` is the load-bearing one and is declared in
`scripts/tests/mutation_manifest.py`. Note what it can and cannot turn on: since an unverifiable
answer fails the job too, disarming the `404` arm still exits non-zero, so the EXIT CODE separates
nothing. What the disarm destroys is the DIAGNOSTIC the outage is reported as "could not verify",
which sends an operator to the registry's health instead of to the rebuild that fixes it.
"""
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "ci-toolchain-image-resolves.sh"
# Stands in for `curl -s -w '\n%{http_code}' -u <auth> -H Accept <url>`: prints a scripted body,
# a newline and the HTTP code, and logs the call. It VALIDATES `-u` rather than ignoring it — a stub
# that answers 200 whether or not the script authenticates would stay green if the real `-u` were
# deleted, which is the fidelity gap that lets a test double certify a script the live registry
# would reject on every request.
CURL_SHIM = r"""#!/usr/bin/env python3
import os, pathlib, sys
state = pathlib.Path(os.environ["STUB_DIR"])
args = sys.argv[1:]
url = [a for a in args if a.startswith("http")][-1]
tag = url.rsplit("/", 1)[-1]
auth = args[args.index("-u") + 1] if "-u" in args else ""
with (state / "calls").open("a") as fh:
fh.write(f"{url} auth={auth}\n")
# The live registry answers 401 to an anonymous read of ANY tag, present or deleted.
user, _, password = auth.partition(":")
if not user or not password:
print("{}\n401", end="")
sys.exit(0)
codes = dict(pair.split("=", 1) for pair in (state / "codes").read_text().split() if pair)
code = codes.get(tag, codes.get("*", "200"))
if code == "TRANSPORT":
# Only the EXIT STATUS is observable: the script's `|| resp=""` discards whatever curl printed,
# so what this reproduces is the non-zero exit, not the `\n000` real curl also emits.
sys.exit(7)
body = '{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json"}'
if code == "200-NOT-A-MANIFEST":
code, body = "200", "<html><title>Sign in</title></html>"
print(f"{body}\n{code}", end="")
"""
WORKFLOW_TEMPLATE = """jobs:
test:
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:{pin}
"""
@pytest.fixture
def preflight(tmp_path):
bindir = tmp_path / "bin"
bindir.mkdir()
shim = bindir / "curl"
shim.write_text(CURL_SHIM)
shim.chmod(0o755)
state = tmp_path / "state"
state.mkdir()
(state / "codes").write_text("*=200")
workflow = tmp_path / "docker-build.yml"
workflow.write_text(WORKFLOW_TEMPLATE.format(pin="32747a0"))
env = dict(os.environ)
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["ETV_CI_WORKFLOW"] = str(workflow)
env["ETV_REGISTRY_AUTH"] = "stub-user:stub-pass"
# The retry PAUSE is what makes failing on an unknown affordable in CI and unaffordable in a
# test suite; the retry COUNT is behaviour, so it is kept and only the wait is removed.
env["ETV_CI_ATTEMPTS"] = "2"
env["ETV_CI_RETRY_SECONDS"] = "0"
class Handle:
def __init__(self):
self.env = env
self.state = state
self.workflow = workflow
self.script = SCRIPT
def set_codes(self, mapping: dict[str, str]):
(state / "codes").write_text(" ".join(f"{k}={v}" for k, v in mapping.items()))
def set_workflow_text(self, text: str):
workflow.write_text(text)
def calls(self):
log = state / "calls"
return log.read_text().splitlines() if log.exists() else []
def run(self, script: Path | None = None):
return subprocess.run(
["bash", str(script or SCRIPT)],
env=env,
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
return Handle()
def test_a_pin_that_resolves_passes(preflight):
preflight.set_codes({"*": "200"})
result = preflight.run()
assert result.returncode == 0, result.stderr
assert "32747a0 resolves" in result.stdout
assert preflight.calls(), "the registry was never queried, so nothing was established"
def test_MUTATION_a_deleted_tag_is_reported_as_a_failure(preflight):
"""The outage of 2026-08-11..13, in one assertion.
Declared in `mutation_manifest.py`: replacing the `404` arm sends a deleted tag down the
could-not-verify path, which fails the job with the wrong story a preflight that runs, reddens,
and still misses the only thing it was built to name.
"""
preflight.set_codes({"32747a0": "404"})
result = preflight.run()
assert result.returncode != 0, (
"a deleted tag did not fail the preflight — the 404 arm is not load-bearing:\n"
f"stdout={result.stdout}\nstderr={result.stderr}"
)
assert "IS GONE" in result.stderr, (
"a deleted tag was not reported as GONE — the 404 arm is not load-bearing. Since an "
"unverifiable answer now fails too, exiting non-zero no longer distinguishes 'the image is "
"deleted' from 'the check could not run', and only this message does:\n"
f"stderr={result.stderr}"
)
assert "32747a0" in result.stderr, "the message must name the tag the operator has to restore"
assert "server-management#842" in result.stderr, "and where the durable fix lives"
@pytest.mark.parametrize("code", ["TRANSPORT", "503"])
def test_an_unknown_answer_FAILS_and_is_not_reported_as_gone(preflight, code):
"""The first draft warned and exited 0 here, which is how a preflight becomes a no-op.
A missing `curl`, a moved registry or a DNS change all land in this arm, and each would have
been green forever. It fails but with its own wording, because "could not verify" and "IS
GONE" send an operator to entirely different places.
"""
preflight.set_codes({"32747a0": code})
result = preflight.run()
assert result.returncode != 0, "an unestablished check must not report success"
assert "could NOT VERIFY" in result.stderr
assert "IS GONE" not in result.stderr, "could-not-tell must never be reported as gone"
def test_an_unknown_is_RETRIED_before_it_fails(preflight):
"""Retries are what make failing on unknown affordable rather than flaky."""
preflight.env["ETV_CI_ATTEMPTS"] = "3"
preflight.set_codes({"32747a0": "503"})
assert preflight.run().returncode != 0
assert len(preflight.calls()) == 3, f"expected 3 attempts, got {preflight.calls()}"
def test_an_ANSWER_is_not_retried(preflight):
"""404 and 200 are answers; retrying them would only slow the job down."""
preflight.set_codes({"32747a0": "404"})
assert preflight.run().returncode != 0
assert len(preflight.calls()) == 1, f"a 404 must not be retried, got {preflight.calls()}"
def test_HTTP_200_with_a_body_that_is_not_a_manifest_is_not_a_pass(preflight):
"""A proxy or a login page answers 200 too; the status line alone establishes nothing."""
preflight.set_codes({"32747a0": "200-NOT-A-MANIFEST"})
result = preflight.run()
assert result.returncode != 0
assert "not a manifest" in result.stderr
assert "IS GONE" not in result.stderr
@pytest.mark.parametrize("code", ["401", "403"])
def test_rejected_credentials_refuse_rather_than_pass(preflight, code):
"""The failure mode that would otherwise make this job green forever.
An anonymous read of this registry is 401 for a live tag and a deleted one alike, so treating
an auth failure as "could not tell, carry on" would turn a broken secret into a permanent,
silent pass.
"""
preflight.set_codes({"32747a0": code})
result = preflight.run()
assert result.returncode != 0
assert "rejected these credentials" in result.stderr
@pytest.mark.parametrize(
("value", "shape"),
[
(None, "unset"),
(":", "both secrets absent — WHAT THE WORKFLOW ACTUALLY PASSES"),
("user:", "password secret absent"),
(":pass", "user secret absent"),
("no-colon", "malformed"),
],
)
def test_unusable_credentials_refuse_BEFORE_querying_anything(preflight, value, shape):
"""The empty-halves cases are the ones that happen, and testing only `unset` misses them.
`ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}` interpolates
a missing secret to the empty string, so a job with no secrets configured passes the non-empty
string ":" which is a perfectly good non-empty value and a useless credential. The registry
answers 401 to it for a live tag and a deleted one alike.
"""
if value is None:
del preflight.env["ETV_REGISTRY_AUTH"]
else:
preflight.env["ETV_REGISTRY_AUTH"] = value
result = preflight.run()
assert result.returncode != 0, f"{shape}: reported success on a credential it cannot use"
assert "ETV_REGISTRY_AUTH" in result.stderr
assert preflight.calls() == [], "it must not query the registry it cannot authenticate to"
def test_the_credential_actually_REACHES_the_registry(preflight):
"""Anti-vacuity for every test above: the stub 401s an unauthenticated read, as the live
registry does, so a script that stopped passing `-u` would redden the whole file rather than
sailing through on a stub that answers 200 regardless."""
preflight.set_codes({"*": "200"})
assert preflight.run().returncode == 0
assert preflight.calls() == [
"http://192.168.1.95:3000/v2/timothy/ersatztv-ci/manifests/32747a0 auth=stub-user:stub-pass"
]
def test_a_workflow_with_no_pin_at_all_is_a_failure(preflight):
"""If the grep stops matching, the honest report is 'I found nothing', not 'all clear'."""
preflight.set_workflow_text("jobs:\n test:\n runs-on: ubuntu-latest\n")
result = preflight.run()
assert result.returncode != 0
assert "no ersatztv-ci pin found" in result.stderr
def test_every_distinct_pin_is_checked_and_one_gone_fails_the_job(preflight):
"""`ci-image-pin` bans a second pin; this must not silently check only the first one anyway."""
preflight.set_workflow_text(
WORKFLOW_TEMPLATE.format(pin="32747a0") + " image: 192.168.1.95:3000/timothy/ersatztv-ci:15d2439\n"
)
preflight.set_codes({"32747a0": "200", "15d2439": "404"})
result = preflight.run()
assert result.returncode != 0
assert "15d2439" in result.stderr
assert len(preflight.calls()) == 2, f"both pins must be queried, got {preflight.calls()}"
def test_the_grep_line_cannot_match_ITSELF(preflight):
"""The pin is found with the same expression `pr-checks.yml::ci-image-pin` uses.
That expression is written into this script's own source, so a careless pattern would find its
own text and 'check' a pin nobody wrote and the same hazard sits in `pr-checks.yml`, whose
pin-count check greps the file this script's job now lives in. Feed the real script its own
source as the workflow file: the answer must be 'no pin found', not a query for `[0-9a-f]+`.
This also pins the second half of the property the source carries no literal pin of its own,
so the file cannot go stale against a pin bump it does not participate in.
"""
preflight.set_workflow_text(SCRIPT.read_text())
result = preflight.run()
assert result.returncode != 0
assert "no ersatztv-ci pin found" in result.stderr
assert preflight.calls() == []
def test_the_PRODUCTION_retry_defaults_are_the_ones_that_run(preflight):
"""Every other test overrides the retry knobs, so nothing evaluated `${VAR:-default}` itself.
That matters because the defaults are the argument: "unknown fails" is only affordable if an
ordinary registry blip is absorbed first. Edited to 1 attempt / 0 seconds, this file would stay
green while a single transient 503 reddened every PR. So this one drops both overrides and
measures the real thing three attempts, and a pause long enough to have actually happened.
"""
del preflight.env["ETV_CI_ATTEMPTS"]
del preflight.env["ETV_CI_RETRY_SECONDS"]
preflight.set_codes({"32747a0": "503"})
started = time.monotonic()
result = preflight.run()
elapsed = time.monotonic() - started
assert result.returncode != 0
assert len(preflight.calls()) == 3, f"the default attempt count is not 3 — got {len(preflight.calls())} call(s)"
assert elapsed >= 8, (
f"two pauses at the default 5s should clear the 8s floor; took {elapsed:.1f}s, so the pause "
"has been shortened out from under the 'a blip does not redden a PR' argument"
)
+10 -11
View File
@@ -744,9 +744,7 @@ def test_wing_faults_block_scalar_record_fails_loudly(tmp_path):
this corpus's very long `rule:` values — makes the whole record silently invisible."""
records, archive = _wing(tmp_path)
bad = records / "ci" / "blockscalar.md"
bad.write_text(
_GOOD.replace("rule: 'a rule on one quoted line'\n", "rule: >-\n a long rule wrapped\n over two lines\n")
)
bad.write_text(_GOOD.replace("rule: 'a rule on one quoted line'\n", "rule: >-\n a long rule wrapped\n over two lines\n"))
# Precondition: this really is the silent-vanish case, not some other parse error.
assert dl.parse_file(bad) == [], "expected the reader to drop the record entirely"
@@ -807,10 +805,8 @@ def test_wing_faults_exempts_stripped_legacy_archive_files(tmp_path):
def test_validate_surfaces_wing_faults_as_errors(tmp_path):
"""Faults must arrive as validator ERRORS (exit 1), not warnings."""
errs = _v(
[_rec(key="ci.a", source=Path("docs/decisions/records/ci/a.md"), heading="A")],
wing_faults=["docs/decisions/records/ci/x.md: parsed to 0 records, expected exactly 1"],
)
errs = _v([_rec(key="ci.a", source=Path("docs/decisions/records/ci/a.md"), heading="A")],
wing_faults=["docs/decisions/records/ci/x.md: parsed to 0 records, expected exactly 1"])
assert any("x.md" in e for e in errs), errs
@@ -830,7 +826,7 @@ def test_wing_faults_sees_a_DEEPER_nested_archive_record(tmp_path):
one level down'."""
records, archive = _wing(tmp_path)
(records / "ci" / "good.md").write_text(_GOOD)
(archive / "api.md").write_text("# api\n\n## Records formerly in this file\n") # still exempt
(archive / "api.md").write_text("# api\n\n## Records formerly in this file\n") # still exempt
deep = archive / "ci" / "sub"
deep.mkdir(parents=True)
(deep / "broken.md").write_text("# not a record\n")
@@ -891,7 +887,8 @@ def test_junk_frontmatter_key_from_a_split_value_is_faulted(tmp_path):
PyYAML rejects this input, so the hand reader is more permissive than the writer."""
records, archive = _wing(tmp_path)
bad = records / "ci" / "corrupt.md"
bad.write_text(_GOOD.replace("rule: 'a rule on one quoted line'\n", "rule: >-\nthe real rule: with a colon\n"))
bad.write_text(_GOOD.replace("rule: 'a rule on one quoted line'\n",
"rule: >-\nthe real rule: with a colon\n"))
recs = dl.parse_file(bad)
assert len(recs) == 1 and recs[0].key, "precondition: this parses to one KEYED record"
assert recs[0].rule == ">-", f"precondition: the real value was truncated, got {recs[0].rule!r}"
@@ -906,7 +903,7 @@ def test_an_empty_or_missing_record_wing_is_LOUD(tmp_path):
missing = dv.record_wing_faults(tmp_path / "nope" / "records", tmp_path / "nope" / "archive")
assert missing and "missing or contains no" in missing[0], missing
records, archive = _wing(tmp_path) # exists but holds no *.md
records, archive = _wing(tmp_path) # exists but holds no *.md
empty = dv.record_wing_faults(records, archive)
assert empty and "missing or contains no" in empty[0], empty
@@ -1440,6 +1437,7 @@ def test_no_budget_flag_means_no_retirement_warning(capsys):
assert "is RETIRED and was IGNORED" not in capsys.readouterr().err
def test_main_reports_ceiling_drift_as_a_NOTICE_and_still_exits_0(capsys):
"""The fine claim's live wiring (#688): the drift notice must fire, and must NOT turn the run
red the entire point of the v5 split.
@@ -1513,7 +1511,8 @@ def test_main_actually_REPORTS_the_ceiling_and_the_trend(capsys):
err = capsys.readouterr().err
assert "prose lines across" in err, "the aggregate trend notice must always print"
over = [r.key for r in dl.all_active_records() if r.key and dv.record_prose_lines(r) > dv.RECORD_CEILING_DEFAULT]
over = [r.key for r in dl.all_active_records()
if r.key and dv.record_prose_lines(r) > dv.RECORD_CEILING_DEFAULT]
warned = "exceed the" in err and "prose ceiling" in err
assert warned is bool(over), f"ceiling warning printed={warned} but {len(over)} record(s) are over it"
if over:
+41 -72
View File
@@ -18,11 +18,9 @@ about a sample, but you CAN mechanically guarantee that every guard has been *cl
and that its claimed proof exists. That converts both rules from "remember to do this" into "the
suite goes red until you have". Specifically:
* the guard population is DERIVED read out of the GIT INDEX for `.claude/hooks/` and
`.husky/`, plus every `scripts/*.sh|py` referenced by a workflow or a hook and compared for
SET EQUALITY against the inventory's rows, in both directions. The index rather than the disk
per ersatztv#806: a filesystem walk reports build output and editor droppings and differs per
machine, so it cannot be the authoritative source a completeness claim needs;
* the guard population is DERIVED globbed from `.claude/hooks/` and `.husky/`, plus every
`scripts/*.sh|py` referenced by a workflow or a hook and compared for SET EQUALITY against the
inventory's rows, in both directions;
* every row's `Proof ref` is resolved to a real file and a real `def` in it;
* every row's `Kind` and `Proof` come from a closed vocabulary, so a typo cannot invent a state.
@@ -37,22 +35,11 @@ import re
from collections import Counter
from pathlib import Path
from scripts.tests import tracked_files
from scripts.tests.tracked_files import tracked_children, tracked_paths
REPO_ROOT = Path(__file__).resolve().parents[2]
INVENTORY = REPO_ROOT / "docs" / "guard-inventory.md"
# THE POPULATION SCOPES, resolved against the GIT INDEX rather than the filesystem (ersatztv#806).
# Directory + patterns instead of `Path.glob`; `scripts/tests/tracked_files.py` carries why the disk
# is not an authoritative source. `.husky` is the sharp case: it holds an untracked `_/` of 17
# npm-generated shims, and the previous `iterdir() ... if p.is_file()` excluded them only because
# `_` happens to be a directory — by accident, not by design, so the obvious "make it recursive"
# edit would have reintroduced #778's third defect inside the repo's own model guard.
HOOKS = (".claude/hooks", ("*.sh",))
HUSKY = (".husky", ("*",))
WORKFLOWS = (".gitea/workflows", ("*.yml", "*.yaml"))
GUARD_TESTS = ("scripts/tests", ("test_*.py",))
HOOKS_DIR = REPO_ROOT / ".claude" / "hooks"
HUSKY_DIR = REPO_ROOT / ".husky"
WORKFLOWS_DIR = REPO_ROOT / ".gitea" / "workflows"
KINDS = {"GUARD", "TOOLING", "PROOF"}
PROOFS = {"MUTATION", "BEHAVIOUR-ONLY", "NONE"}
@@ -77,52 +64,30 @@ _SUMMARY = re.compile(
def derived_guard_files() -> set[str]:
"""THE AUTHORITATIVE POPULATION, from the git index and the call sites — never a list.
"""THE AUTHORITATIVE POPULATION, from the filesystem and the call sites — never a list.
Four contributors, unioned: three scope directories plus the paths those files REFERENCE. The
hook and husky directories are taken whole, so a new hook is in the population the moment it is
STAGED. The scripts half is discovered by scanning what the workflows and hooks actually
INVOKE, rather than taking `scripts/` whole a script nothing calls is not a guard, and taking
it whole would drag in every helper and make the inventory a chore that gets rubber-stamped.
Both halves are gated on the index: the callers by `tracked_paths`, the targets they name by the
`tracked` set below.
"The moment it is STAGED" rather than "the moment it exists" is the ersatztv#806 change, and
it is a strengthening: an untracked `foo.sh` dropped in `.claude/hooks/` used to enter this
population and demand an inventory row for a file that is not part of the repo red on that
checkout, green in CI, which is #778's third shape. Nothing weakens, because a guard that is not
staged is not on its way to anyone else either.
Three sources, unioned. The hook and husky directories are globbed whole, so a new hook is in
the population the moment it exists. The scripts half is discovered by scanning what the
workflows and hooks actually INVOKE, rather than globbing `scripts/` a script nothing calls is
not a guard, and globbing would drag in every helper and make the inventory a chore that gets
rubber-stamped.
"""
found = (
tracked_children(*HOOKS)
| tracked_children(*HUSKY)
# `pr-checks.yml` runs `pytest scripts/tests` as a directory, so every file in it is invoked
# and none is individually named anywhere. Taking the directory whole is the only derivation
# that matches how they actually run.
| tracked_children(*GUARD_TESTS)
)
found = {
str(p.relative_to(REPO_ROOT)) for p in HOOKS_DIR.glob("*.sh")
} | {
str(p.relative_to(REPO_ROOT)) for p in HUSKY_DIR.iterdir() if p.is_file()
} | {
# `pr-checks.yml` runs `pytest scripts/tests` as a directory, so every file in it is
# invoked and none is individually named anywhere. Globbing is the only derivation that
# matches how they actually run.
str(p.relative_to(REPO_ROOT)) for p in (REPO_ROOT / "scripts" / "tests").glob("test_*.py")
}
# THE REFERENCED TARGETS ARE GATED ON THE INDEX, NOT ON `Path.exists()`. Converting the CALLERS
# and leaving the members they contribute on a disk check would have left a quarter of this
# population answering a question about the machine: a tracked workflow naming
# `scripts/generated/helper.sh` that exists on one laptop only would enter there, demand an
# inventory row for a file that is not in the repo, and go red on that checkout while CI stayed
# green — #778's third shape, in the guard this file calls its model.
#
# A referenced path that git does not track is therefore dropped silently, and that is the right
# residual rather than an assertion: `_SCRIPT_REF` matches any occurrence, including inside a
# comment or an `::error::` string (limit 3 in `docs/guard-inventory.md`), so demanding that
# every matched path be tracked would redden a correct tree on a prose mention.
# Called through the MODULE, never `from … import _git_ls_files`. A direct name binding is
# captured at import time, and the exhaustive proof in
# `test_guard_populations_derive_from_git.py` then cannot narrow the index for this branch at
# all — every referenced target reports as surviving removal, which is a red for the wrong
# reason and, worse, means the branch is untested however the proof reads.
tracked = set(tracked_files._git_ls_files())
callers = tracked_paths(*WORKFLOWS) + tracked_paths(*HOOKS) + tracked_paths(*HUSKY)
callers = list(WORKFLOWS_DIR.glob("*.yml")) + list(HOOKS_DIR.glob("*.sh"))
callers += [p for p in HUSKY_DIR.iterdir() if p.is_file()]
for caller in callers:
for ref in _SCRIPT_REF.findall(caller.read_text()):
if ref in tracked:
if (REPO_ROOT / ref).exists():
found.add(ref)
return found
@@ -146,9 +111,15 @@ def wired_hook_files() -> set[str]:
comments, so every occurrence there is in a real command string.
"""
text = (REPO_ROOT / ".claude" / "settings.json").read_text()
for husky in tracked_paths(*HUSKY):
text += "\n".join(line for line in husky.read_text().splitlines() if not line.lstrip().startswith("#"))
return {rel for rel in tracked_children(*HOOKS) if rel.rpartition("/")[2] in text}
for husky in HUSKY_DIR.iterdir():
if husky.is_file():
text += "\n".join(
line for line in husky.read_text().splitlines()
if not line.lstrip().startswith("#")
)
return {
str(p.relative_to(REPO_ROOT)) for p in HOOKS_DIR.glob("*.sh") if p.name in text
}
def inventory_rows() -> list[tuple[str, str, str, str]]:
@@ -243,8 +214,8 @@ def test_every_claimed_proof_names_a_test_that_exists():
def test_every_hook_file_is_actually_WIRED():
"""A hook file nothing registers is dead code holding an inventory row that reads as coverage."""
staged = tracked_children(*HOOKS)
unwired = sorted(staged - wired_hook_files())
on_disk = {str(p.relative_to(REPO_ROOT)) for p in HOOKS_DIR.glob("*.sh")}
unwired = sorted(on_disk - wired_hook_files())
assert not unwired, (
f"these hook files exist and have inventory rows but are referenced by neither "
f".claude/settings.json nor any .husky/ hook: {unwired}. They do not run. Either wire them "
@@ -264,7 +235,9 @@ def test_proof_rows_do_not_themselves_claim_a_proof():
for guard, kind, proof, ref in inventory_rows():
if kind == "PROOF":
assert proof == "NONE", f"{guard} is PROOF but claims Proof {proof}"
assert guard.startswith("scripts/tests/"), f"{guard} is marked PROOF but does not live in scripts/tests/"
assert guard.startswith("scripts/tests/"), (
f"{guard} is marked PROOF but does not live in scripts/tests/"
)
assert ref in ("", "-", ""), f"{guard}: PROOF rows carry no proof ref"
@@ -310,12 +283,8 @@ def test_the_summary_counts_match_the_table():
)
claimed = tuple(int(g) for g in m.groups())
actual = (
kinds["GUARD"],
kinds["TOOLING"],
kinds["PROOF"],
grades["MUTATION"],
grades["BEHAVIOUR-ONLY"],
grades["NONE"],
kinds["GUARD"], kinds["TOOLING"], kinds["PROOF"],
grades["MUTATION"], grades["BEHAVIOUR-ONLY"], grades["NONE"],
)
assert claimed == actual, (
f"the summary claims (guards, tooling, proofs, mutation, behaviour-only, none) = {claimed} "
@@ -1,604 +0,0 @@
"""No file population in this repo admits a file git does not track (ersatztv#806).
The regression for #778's third defect, hoisted to cover every guard that shares the mechanism
rather than being copied into each of them. `Path.rglob` enumerated `.husky/_/` 17 husky shims
generated by `npm ci`, gitignored and untracked so `test_remote_state_inventory.py` was RED on
every developer checkout and GREEN in CI, whose `script-tests` job checks out and pip-installs but
never runs `npm ci`. A guard that fails everywhere except where it runs trains its readers to ignore
it, and it did so on the artifact whose entire thesis is population correctness.
TWO PROOFS, because they fail differently and either alone leaves a hole.
* `test_the_primitive_REALLY_excludes_an_untracked_file` builds a throwaway git repo, commits one
file, leaves an identical sibling untracked, and runs the real derivation against it. It proves
the mechanism by EXECUTING it rather than by recognising its shape no monkeypatching, no
stand-in for git. Nothing here is a claim about `git ls-files`; it is `git ls-files`.
* `test_no_derivation_admits_an_untracked_file` narrows the tracked set under each real derivation
and requires the dropped member to vanish from the population even though the file is still on
disk and still matches the scope. That is the property stated over the ACTUAL guards, so a
future refactor that quietly reintroduces a filesystem walk in any one of them fails here rather
than on somebody's laptop.
`DERIVATIONS` is the reason this file is not one near-copy per derivation: a guard that starts deriving a file
population registers here, and both proofs cover it for free. The register is hand-written and that
is a SCOPE decision, not a population one per `testing.guard-derives-population-from-source`, a
scope mirroring an authoritative source needs its own equality check, and
`test_every_index_derived_module_is_registered` is it: it reads which modules import the shared
helper and demands each one appear below, so adding another derivation and forgetting this file is
red rather than silently uncovered.
"""
from __future__ import annotations
import ast
import glob
import os
import subprocess
import sys
from pathlib import Path
import pytest
from scripts.tests import test_ci_image_pin_population as image_pin
from scripts.tests import test_guard_inventory as guard_inventory
from scripts.tests import test_hook_fire_log as hook_fire
from scripts.tests import test_pr_changed_files as pr_changed
from scripts.tests import test_remote_state_inventory as remote_state
from scripts.tests import tracked_files
REPO_ROOT = Path(__file__).resolve().parents[2]
TESTS_DIR = REPO_ROOT / "scripts" / "tests"
class _EmptyScan:
"""An exhausted ITERATOR that is also a context manager, standing in for `os.scandir`.
Both halves are load-bearing and each was missing in turn. `os.scandir` is used as
`with os.scandir(...) as it`, so a bare iterator broke the context-manager protocol; and
`os.walk` does `entry = next(scandir_it)` on the result, so an ITERABLE defining only
`__iter__` broke that. Either way the enumeration assertion still fired with the right message,
but the report also carried a TypeError about the harness and a finding that arrives beside a
harness error invites doubting the finding rather than the code.
"""
def __iter__(self):
return self
def __next__(self):
raise StopIteration
def __enter__(self):
return self
def __exit__(self, *_exc):
return False
def close(self):
return None
def _as_relative_strings(members) -> set[str]:
"""Derivations return either repo-relative strings or absolute `Path`s; compare on one form.
Iterating into a set is also what DRAINS a derivation that returns a generator, which the
enumeration proof depends on see its call site.
An unexpected member type is REPORTED rather than stringified. `str(m)` on anything at all meant
a derivation yielding, say, nested generators produced plausible-looking members and compared
equal to nothing, which is a population check passing over data it did not understand.
"""
out = set()
for m in members:
assert isinstance(m, str | Path), (
f"a derivation yielded {type(m).__name__} ({m!r}); populations here are repo-relative "
"strings or absolute Paths, and stringifying anything else would compare a plausible "
"value against a set that can never contain it."
)
out.add(str(Path(m).relative_to(REPO_ROOT)) if isinstance(m, Path) else str(m))
return out
# (label, callable, a floor below which the derivation has plainly broken). `test_hook_fire_log`
# floors the same population at the same number for its own coverage assertions; that is not a
# duplicate guard masking another, because the two protect different consumers from going
# vacuous — delete this one and THIS file's proofs iterate over nothing while reporting success.
DERIVATIONS = (
("test_guard_inventory.derived_guard_files", guard_inventory.derived_guard_files, 25),
("test_hook_fire_log.hook_scripts", hook_fire.hook_scripts, 10),
("test_ci_image_pin_population.workflow_files", image_pin.workflow_files, 5),
("test_remote_state_inventory.derived_population", remote_state.derived_population, 40),
("test_pr_changed_files._workflow_files", pr_changed._workflow_files, 5),
)
# The floor matters only to the anti-vacuity test; the two property tests take the pair, so an
# unused parameter cannot drift into looking like an assertion they make.
_IDS = [d[0] for d in DERIVATIONS]
_PAIRS = [(label, derive) for label, derive, _ in DERIVATIONS]
# Modules that import the shared derivation WITHOUT deriving a guard population. Kept here, beside
# DERIVATIONS, so adding one is an edit to this file that a reviewer sees.
POPULATION_EXEMPT = {
# Uses the index to assemble a HERMETIC tmp fixture copy; nothing in it asserts membership.
"test_ci_release_path_scan_job.py": "index-derived fixture copy, not a population",
}
_HELPER = "scripts.tests.tracked_files"
_PACKAGE = ["scripts", "tests"]
# ------------------------------------------------------------------------------------------------
# ANTI-VACUITY FIRST — every assertion below compares sets, and a derivation that collapsed to
# nothing would satisfy all of them while proving nothing.
# ------------------------------------------------------------------------------------------------
@pytest.mark.parametrize(("label", "derive", "floor"), DERIVATIONS, ids=_IDS)
def test_each_derivation_found_something(label, derive, floor):
members = _as_relative_strings(derive())
assert len(members) >= floor, (
f"{label} derived only {len(members)} members, below its floor of {floor} — the derivation "
"is broken, not the repo, and every set comparison built on it is vacuous."
)
# ------------------------------------------------------------------------------------------------
# PROOF 1 — the primitive, executed against a real git repo rather than described
# ------------------------------------------------------------------------------------------------
def test_the_primitive_REALLY_excludes_an_untracked_file(tmp_path, monkeypatch):
"""A tracked and an untracked file, identical in name shape and both on disk. Only one is in.
Run rather than reasoned about. This class of defect is produced by arguments about what a
traversal WOULD enumerate, and such arguments are locally convincing whether or not they are
right; only executing the traversal distinguishes the two.
"""
repo = tmp_path / "repo"
(repo / ".claude" / "hooks").mkdir(parents=True)
(repo / ".claude" / "hooks" / "committed.sh").write_text("#!/bin/sh\n")
(repo / ".claude" / "hooks" / "untracked.sh").write_text("#!/bin/sh\n")
def git(*args):
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
git("init", "-q")
git("config", "user.email", "guard@example.invalid")
git("config", "user.name", "guard")
git("add", ".claude/hooks/committed.sh")
git("commit", "-qm", "one tracked hook")
monkeypatch.setattr(tracked_files, "REPO_ROOT", repo)
found = tracked_files.tracked_children(".claude/hooks", ("*.sh",))
assert (repo / ".claude" / "hooks" / "untracked.sh").is_file(), (
"the untracked file must still be on disk, or this proves nothing about the index winning over the filesystem"
)
assert found == {".claude/hooks/committed.sh"}, (
f"the derivation returned {sorted(found)}. A filesystem walk returns both files here; only "
"the index distinguishes them, and that difference is the entire point of ersatztv#806."
)
def test_the_primitive_does_not_recurse_into_an_untracked_subdirectory(tmp_path, monkeypatch):
"""`.husky/_/` in miniature — the shape that made #778 red on every checkout.
Even a TRACKED nested file must stay out: `tracked_children` is direct-children-only by design,
and recursion is what dragged the shims in. Proving it with a tracked file makes the assertion
about the traversal rather than about the index, so the two properties cannot mask each other.
"""
repo = tmp_path / "repo"
(repo / ".husky" / "_").mkdir(parents=True)
(repo / ".husky" / "pre-commit").write_text("#!/bin/sh\n")
(repo / ".husky" / "_" / "husky.sh").write_text("#!/bin/sh\n")
def git(*args):
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
git("init", "-q")
git("config", "user.email", "guard@example.invalid")
git("config", "user.name", "guard")
git("add", "-A")
git("commit", "-qm", "husky plus a nested shim, both tracked")
monkeypatch.setattr(tracked_files, "REPO_ROOT", repo)
assert tracked_files.tracked_children(".husky", ("*",)) == {".husky/pre-commit"}, (
"a nested file entered a flat population — this is the `.husky/_/` shape, and it was red on "
"every developer checkout the last time it shipped"
)
def test_an_empty_index_FAILS_LOUDLY_rather_than_reporting_an_empty_population(tmp_path, monkeypatch):
"""The floor under every floor. A silent empty population is how a completeness guard reports
total coverage having examined nothing, which is the failure mode this repo has shipped twice
(#631, #751)."""
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True, capture_output=True)
monkeypatch.setattr(tracked_files, "REPO_ROOT", repo)
with pytest.raises(AssertionError, match="reported nothing"):
tracked_files.tracked_children(".claude/hooks", ("*.sh",))
# ------------------------------------------------------------------------------------------------
# PROOF 2 — the property, over the real guards
# ------------------------------------------------------------------------------------------------
@pytest.mark.parametrize(("label", "derive"), _PAIRS, ids=_IDS)
def test_no_derivation_admits_an_untracked_file(label, derive):
"""Narrow the index, leave the disk alone, and require the member to disappear — for EVERY
member, one at a time.
EXHAUSTIVE RATHER THAN ONE VICTIM, and the difference is not thoroughness for its own sake.
`derived_guard_files` unions four sources; one victim is always drawn from whichever sorts
first, so a mutant putting only the third source back on a filesystem walk passes while the
proof reports on all four. A sample cannot see the source it did not draw from this file
applying `testing.guard-derives-population-from-source` to itself.
The floors cannot substitute, and the numbers say why. Reproduce with:
PYTHONPATH=. python3 -c "from scripts.tests import test_guard_inventory as g; \
print(len(g.derived_guard_files()))"
61 members on 2026-08-22; suppressing a single contributor leaves 39 (`scripts/tests`), 48
(hooks) or 58 (husky), all far above the anti-vacuity floor of 25. The figures move whenever a
guard is added they were 60/39/47/57 one commit earlier so read them as an illustration of
the GAP, not as values to assert against. A floor tight enough to catch a lost source
would go red every time a guard is legitimately deleted, which is the wrong instrument.
Its own `monkeypatch` context, never the shared fixture instance: the function-scoped fixture is
the same object `conftest.py`'s autouse `isolate_hook_fire_log` patched, so calling `undo()` on
it here also unsets `ETV_HOOK_FIRE_LOG_DIR` and silently re-points a later hook-driving test at
the PRODUCTION log the #776 isolation disarmed from inside the file that argues for proofs.
WHAT REMOVAL CANNOT SEE, so it is not read as more than it is: a source contributing ONLY
untracked members has nothing here to remove, and #778's defect was exactly that shape (an
`rglob` over `.husky/_/` adds 17 untracked members and removes none). That direction is
`test_no_derivation_ENUMERATES_the_filesystem` below; the two are complements, not duplicates.
"""
before = _as_relative_strings(derive())
assert before, f"{label} derived nothing; there is no victim to remove"
real = tracked_files._git_ls_files()
survivors = []
absent = []
with pytest.MonkeyPatch.context() as m:
for victim in sorted(before):
if not (REPO_ROOT / victim).is_file():
absent.append(victim)
continue
m.setattr(tracked_files, "_git_ls_files", lambda v=victim: [p for p in real if p != v])
if victim in _as_relative_strings(derive()):
survivors.append(victim)
assert not absent, (
f"{label} contains {absent}, which git tracks but are not on disk. The proof below asserts "
"that the INDEX decides while the file is still present; it cannot mean that for a member "
"that is missing, so this is reported rather than skipped."
)
assert not survivors, (
f"{label} still contains {survivors} after git stopped tracking them. Every one of those is "
"still on disk, so the derivation is reading the filesystem for that member and untracked "
"build output can redden it on a developer checkout while CI stays green (ersatztv#778)."
)
# The directory-listing APIs a Python file population is realistically written with. NOT every way a
# process can list a directory — `subprocess.run(["ls"])`, a module-level alias captured before the
# patch, and any C-level call all walk straight past this, all three verified by cold review. That
# bounds what the check below can claim, and the docstring says so rather than implying a sandbox.
# Reading a file stays allowed: `derived_guard_files` must read workflow bodies.
_ENUMERATORS = (
(Path, "glob"),
(Path, "rglob"),
(Path, "iterdir"),
(Path, "walk"),
(os, "listdir"),
(os, "walk"),
(os, "scandir"),
(glob, "glob"),
(glob, "iglob"),
)
@pytest.mark.parametrize(("label", "derive"), _PAIRS, ids=_IDS)
def test_no_derivation_ENUMERATES_the_filesystem(label, derive):
"""The ADD direction, without arranging any state: a derivation may READ files, but it may not
LIST a directory while it runs.
WHY THE ADD DIRECTION NEEDS ITS OWN TEST. Removing members from the index cannot see a source
that contributes ONLY untracked members: it adds and never takes away, so nothing of its is
available to remove. `.husky/_/` is that shape exactly 17 shims `npm ci` writes, none ever in
the index and it is the shape #778 shipped. Scope the claim precisely: a source appending
`(REPO_ROOT / ".husky" / "_").rglob("*")` leaves the removal proof green ON A MACHINE WHERE THAT
DIRECTORY IS ABSENT, which is the `script-tests` checkout. Where the shims exist, the appended
members are present and removal reddens too. The blind spot is an append-only source that yields
nothing HERE which is exactly the CI shape, and exactly where a guard going quiet matters.
WHY THE PROPERTY IS "DOES NOT ENUMERATE" RATHER THAN "DOES NOT RETURN AN UNTRACKED FILE". Both
obvious formulations of the latter are machine-dependent, which is the very fault #806 exists to
remove:
* creating real probe files in the checkout and requiring they not enter. That needs a
`test_*.py` probe to reach the `scripts/tests` scope a file pytest COLLECTS mid-session
and its parametrised names collide across `-n auto` workers and concurrent sessions,
`finally` does not survive SIGKILL, and a concurrent `git add -A` can stage one. Defects in
the test, not in the thing tested.
* neutralising the shared primitive and requiring the derivation to go empty. That misses the
`.husky/_/` source on any machine where `.husky/_/` does not exist which is every CI
checkout of `script-tests`, which never runs `npm ci`. Green where it runs, red only on a
laptop: the inverted asymmetry again, inside the proof written to abolish it.
Watching for the CALL needs no arranged state: an `rglob` issued while deriving is caught even
where the directory it walks is empty, because the evidence is the call rather than what it
returned. Reading is untouched, so a derivation may still parse the workflow bodies it scrapes
for referenced scripts.
WHAT IT DOES NOT COVER. The boundary is not "synchronous", which is what two earlier drafts
said and what measurement disproved a thread that outlives the `derive()` call but finishes
while its result is being drained IS caught, as is a `__del__` firing during that drain. The
boundary is mechanical rather than temporal: a call to one of the SPIES is observed, wherever and
whenever it happens in this process before the assertion below. "While the patch is active"
under-claims it the spy appends to a list that outlives the patch, so a reference captured
during the window and invoked after it still records. What decides observation is whether the
call goes through a spy, not when. The result is drained while the patch is installed, so a lazy
generator is reached.
NOT REACHED, because no spy was ever installed on that path. Enumeration HOISTED TO MODULE SCOPE
runs at import, before this test exists the likeliest instance rather than a contrivance, since
`test_ci_image_pin_population.py` already precomputes `_DOC` that way as does an `atexit` hook,
and a cached property warmed by the baseline call below. `from os import listdir` binds the real
function before any patch; `from os import walk` IS caught (it routes through the patched
`os.scandir`) and so is `from glob import glob` (through the patched `glob.iglob`). And anything
listing in ANOTHER PROCESS a deliberate `subprocess.run(["ls"])`, or a forked child. This is a
regression guard against the shapes that arrive by accident, not a sandbox.
It cuts the other way too: any spy call at all reddens this test, so unrelated background thread
activity touching a patched name during the window would too. Nothing in this suite does that
today, and the report names the call, so a false red would be diagnosable rather than
mysterious.
It also cannot see a derivation that admits a HARDCODED path without listing anything
(`if (REPO_ROOT / "x.sh").exists(): add`) listing is the commonest way to discover an untracked
member, not the only one. The removal proof above catches that shape, and catches memoisation,
which this one cannot: the baseline call below warms any cache outside the patch. The two are
complements.
"""
assert derive(), f"{label} derived nothing; this proof needs a baseline"
calls: list[str] = []
def _spy(what):
# RECORDS and returns empty rather than raising. Raising made the assertion "did an
# exception reach us", which a derivation defeats by catching it: a `try: ... except
# Exception: return set()` around an `rglob` enumerated the filesystem and this test passed,
# measured. The evidence is the CALL, so the call is what is asserted on.
def spy(*_args, **_kwargs):
calls.append(what)
# Iterator AND context manager: `os.scandir` is used as `with os.scandir(...) as it`,
# and a bare iterator made the failure report carry a test-induced TypeError about the
# context manager protocol alongside the real finding. The assertion fired correctly
# either way, but a report that blames the harness invites doubting the finding.
return _EmptyScan()
return spy
failure = None
with pytest.MonkeyPatch.context() as m:
for owner, name in _ENUMERATORS:
m.setattr(owner, name, _spy(f"{getattr(owner, '__name__', owner)}.{name}"), raising=False)
try:
# DRAINED inside the context, never `derive()` discarded. A derivation returning a lazy
# generator does its work when the caller drains it, so discarding the result moved the
# whole walk outside the patch: a generator yielding the index population and then
# appending `.husky/_` passed here and admitted 17 untracked shims on a checkout where
# that directory exists. `_as_relative_strings` is what drains it — it iterates into a
# set — so the call must stay here rather than being hoisted out or wrapped in something
# lazier.
_as_relative_strings(derive())
except BaseException as exc: # re-raised below, after the evidence has been judged
failure = exc
assert not calls, (
f"{label} enumerated the filesystem via {sorted(set(calls))} while deriving its population. "
"Directory listings report build output, generated shims and editor droppings, and differ "
"between the CI checkout and a developer's, so the member set stops being a property of the "
f"repo (ersatztv#778, #806)." + (f" It also raised: {failure!r}" if failure is not None else "")
)
if failure is not None:
raise failure
@pytest.mark.parametrize(("label", "derive"), _PAIRS, ids=_IDS)
def test_every_derived_member_is_tracked(label, derive):
"""The same property as an invariant over the real tree, which is the form that catches a
refactor going back to a filesystem walk without also touching this file."""
tracked = set(tracked_files._git_ls_files())
stray = sorted(m for m in _as_relative_strings(derive()) if m not in tracked)
assert not stray, f"{label} contains untracked path(s): {stray}"
# ------------------------------------------------------------------------------------------------
# THE SCOPE MIRROR ABOVE IS ITSELF CHECKED
# ------------------------------------------------------------------------------------------------
def _modules_importing_the_helper() -> set[str]:
"""Which `scripts/tests/test_*.py` import the shared derivation, by PARSING them.
`ast` rather than a substring scan, and the distinction is the point rather than tidiness. A
substring scan over source both EVADES and FALSELY FIRES here: `import scripts.tests.
tracked_files as tf` and `from scripts.tests import tracked_files as tf` escape a scan for
`"tracked_files import"`, while a comment merely citing `scripts/tests/tracked_files.py` matches
a scan for `"tracked_files."` and would redden a correct tree over prose.
`docs/decisions/records/testing/guard-derives-population-from-source.md` records why patching
such a predicate does not converge: it is not a parser. Python ships the parser, and a comment
is not a node at all.
WHAT THE PARSE DOES NOT REACH, stated rather than implied by the word "parsing": STATIC import
statements naming the helper. `importlib.import_module("scripts.tests.tracked_files")`, a
re-export through `scripts/tests/__init__.py`, and `from scripts.tests import *` are invisible,
verified by executing each. (`from scripts.tests.tracked_files import *` IS seen it names the
module.) Those sit inside the same residual as a module that derives a population without the
helper at all the residual named below and no mechanical check closes it.
The FILE LIST is a filesystem walk on purpose, and it is not the defect this file forbids: it is
a superset check over what pytest itself collects, so an untracked stray `test_x.py` here makes
the guard MORE demanding, never blind. Using the index would let an unstaged new guard escape
registration, which is the wrong direction for a check about coverage.
"""
found = set()
for path in sorted(TESTS_DIR.glob("test_*.py")):
for node in ast.walk(ast.parse(path.read_text())):
# RESOLVE the name to an absolute module and compare exactly. Testing `base ==
# "tracked_files"` handled `from .tracked_files import x` but silently missed
# `from ..tests.tracked_files import x`, which resolves to the same helper — a false
# NEGATIVE, the direction that lets a module adopt the helper and escape registration.
# A suffix match instead over-accepts `from unrelated.package import tracked_files`,
# reddening a correct tree over a module this repo does not own. Resolution is the only
# form with neither failure: these files live in `scripts.tests`, so level 1 resolves to
# `scripts.tests` and level 2 to `scripts` — see the guard below for why there is no
# level 3.
if isinstance(node, ast.Import):
hit = any(a.name == _HELPER for a in node.names)
elif isinstance(node, ast.ImportFrom):
if node.level > len(_PACKAGE):
# Beyond the top-level package: Python raises ImportError for this, so it cannot
# be an import of the helper. Guarded explicitly because `_PACKAGE[:negative]`
# silently WRAPS — level 4 produced the same prefix as level 2 — which pinned an
# unimportable form as a valid detection. Valid levels here are exactly 1 and 2:
# 1 resolves to `scripts.tests`, 2 to `scripts`, and 3 or more is beyond the
# top-level package, which Python refuses.
continue
prefix = _PACKAGE[: len(_PACKAGE) - (node.level - 1)] if node.level else []
base_parts = prefix + ([node.module] if node.module else [])
base = ".".join(base_parts)
hit = base == _HELPER or any(f"{base}.{a.name}" == _HELPER for a in node.names)
else:
continue
if hit:
found.add(path.name)
break
return found
# (source, should the matcher see it). Every row is a form that has actually been mis-classified; the
# table is here so the next edit to `_modules_importing_the_helper` cannot re-open one silently.
# A false NEGATIVE lets a module adopt the helper and escape registration; a false POSITIVE reddens
# a correct tree over a module this repo does not own. Both directions are pinned.
_IMPORT_FORMS = (
("import scripts.tests.tracked_files as tf", True),
("from scripts.tests import tracked_files as tf", True),
("from scripts.tests.tracked_files import tracked_paths", True),
("from scripts.tests.tracked_files import *", True),
("from . import tracked_files", True),
("from .tracked_files import tracked_paths", True),
("from ..tests.tracked_files import tracked_paths", True),
# Beyond the top-level package from `scripts.tests`: Python raises ImportError, so there is
# nothing to detect. Pinned False so the negative-slicing wrap that once made it look
# detectable cannot come back.
("from ...scripts.tests.tracked_files import tracked_paths", False),
# Witnesses the negative-slice wrap specifically: without the level guard this one
# resolves through `scripts` and matches.
("from ....tests.tracked_files import tracked_paths", False),
("def f():\n from scripts.tests import tracked_files\n return tracked_files", True),
("from unrelated.package import tracked_files", False),
("from ..something import tracked_files", False),
("from .something import tracked_files", False),
("from ..other.tracked_files import x", False),
("# see scripts/tests/tracked_files.py for the rationale\nimport re", False),
# Pins PARSER VISIBILITY, not importability: executing this line really does import the helper.
# The row records that a static parse cannot see it — a known gap, pinned so it is not a
# surprise — and closing it would mean updating this row, which is the intended friction.
("import importlib\nimportlib.import_module('scripts.tests.tracked_files')", False),
)
@pytest.mark.parametrize(("source", "expected"), _IMPORT_FORMS, ids=[s.splitlines()[0][:48] for s, _ in _IMPORT_FORMS])
def test_the_import_matcher_classifies_every_reviewed_form(source, expected, monkeypatch):
class _Fake:
name = "test_probe.py"
def read_text(self):
return source
def __lt__(self, other):
return True
class _Dir:
def glob(self, _pattern):
return [_Fake()]
monkeypatch.setattr(sys.modules[__name__], "TESTS_DIR", _Dir())
seen = "test_probe.py" in _modules_importing_the_helper()
assert seen is expected, f"the import matcher {'missed' if expected else 'falsely matched'} this form:\n{source}"
def test_every_index_derived_module_is_registered():
"""`DERIVATIONS` is a hand-written mirror, so it gets an equality check rather than a promise.
Without this, adding another index-derived guard and forgetting to register it leaves that guard
unproven while this file reads as covering them all a completeness claim standing behind a
hand-maintained list, which is the defect one altitude up (#773 Family C, and the shape that put
`MARKED_JOBS` in the record as a residual gap).
THE SCOPE THIS CANNOT SEE, stated because a check described as complete stops being re-examined:
it detects modules that IMPORT the shared helper. A module deriving a file population some other
way shelling out to `git ls-files` itself, or going back to `Path.rglob` is invisible to it,
and no mechanical check can close that. `test_guard_inventory.py`'s own header argues the same
point about flagging filter-shaped guards by token, and #774 concluded there that the honest
answer is no. What is mechanised here is the case that actually recurs: someone adopts the
helper and forgets this file.
Registration is MODULE-level, not derivation-level, so a second population added inside an
already-registered module is covered only if it is registered too.
`POPULATION_EXEMPT` is the opt-out, and it lives HERE rather than as a marker comment in the
exempt file because the two directions are not symmetric: a false import-match only reddens,
while a false EXEMPTION is silent. A marker a file grants itself by containing a token is
trippable from that file's prose — this file's own error message names the token so it would
be a one-line silent kill switch, the shape `test_ci_release_path_scan_job.py` argues against
for its recursion fence. Listing exemptions beside the registrations makes adding one a visible
edit here.
WHAT NO ASSERTION CAN DECIDE, dated so it is re-examined rather than assumed: whether an
exemption is still WARRANTED. A stale key and an unexplained one are both caught below, but an
exempt module that later grows a real derived population stays uncovered and silent. Reviewed
2026-08-22 the single entry uses the helper only to assemble a tmp fixture copy and asserts
nothing about membership.
"""
exempt = {Path(__file__).name} | set(POPULATION_EXEMPT)
importers = _modules_importing_the_helper()
registered = {label.split(".", 1)[0] + ".py" for label, _, _ in DERIVATIONS}
# ANTI-VACUITY, and only that. A broken parse is caught loudly by `phantom` below — every
# registered module would go missing at once — so this is the cheaper, more specific signal, not
# the thing standing between a broken parse and a green run.
assert len(importers) >= len(DERIVATIONS), (
f"the import parse found only {sorted(importers)}, fewer modules than DERIVATIONS registers "
f"({sorted(registered)}) — the parse has broken."
)
stale = sorted(name for name in POPULATION_EXEMPT if name not in importers)
assert not stale, (
f"POPULATION_EXEMPT lists {stale}, which the parser no longer sees importing the shared "
"helper (renamed, "
"deleted, or the import removed). A stale exemption is worse than none: if the filename is "
"ever reused, the new module is exempt from birth without anyone deciding that."
)
thin = sorted(name for name, why in POPULATION_EXEMPT.items() if not str(why).strip())
assert not thin, f"POPULATION_EXEMPT entries with no stated reason: {thin}"
unregistered = sorted(importers - registered - exempt)
assert not unregistered, (
f"{unregistered} import the shared index derivation but are not in DERIVATIONS, so neither "
"proof in this file covers them. Add a named derivation function and register it, or add "
"the module to POPULATION_EXEMPT in this file if it imports the helper without deriving a "
"population."
)
phantom = sorted(registered - importers)
assert not phantom, (
f"DERIVATIONS registers {phantom}, which no longer import the shared helper. A row for "
"a derivation that is not there reads as coverage and is not."
)
+211 -346
View File
@@ -2,12 +2,9 @@
Two separable claims, and conflating them is how instrumentation ships as a regression:
1. COVERAGE every hook script records its own execution. The population is DERIVED from the
tracked `.claude/hooks/*.sh`, never listed, per `testing.guard-derives-population-from-source`,
so a hook staged tomorrow is uninstrumented-and-red rather than silently unobserved. From
the git index rather than a filesystem walk since ersatztv#806 — an untracked file on one
machine is not part of the repo, and a guard whose population differs per checkout is one
nobody trusts.
1. COVERAGE every hook script records its own execution. The population is DERIVED from
`.claude/hooks/*.sh`, never listed, per `testing.guard-derives-population-from-source`, so a
hook added tomorrow is uninstrumented-and-red rather than silently unobserved.
2. TRANSPARENCY the wrapper is invisible to the harness. It slurps stdin and replays it, and it
diverts stdout and replays it, which puts it directly in the path of the most load-bearing
@@ -30,20 +27,14 @@ that is fine alone and lethal in context.
from __future__ import annotations
import os
import pty
import re
import subprocess
import pty
from pathlib import Path
import pytest
from scripts.tests.tracked_files import tracked_paths
REPO_ROOT = Path(__file__).resolve().parents[2]
# Directory + patterns resolved against the GIT INDEX, not `Path.glob` (ersatztv#806) — see
# `scripts/tests/tracked_files.py`. `HOOKS_DIR` survives only for error messages.
HOOKS = (".claude/hooks", ("*.sh",))
HUSKY = (".husky", ("*",))
HOOKS_DIR = REPO_ROOT / ".claude" / "hooks"
SINK = REPO_ROOT / "scripts" / "hook-fire-log.sh"
@@ -55,15 +46,8 @@ _BEGINS = re.compile(r"^etv_hook_fire_begin (\S+) .*\|\| true$", re.M)
def hook_scripts() -> list[Path]:
"""THE POPULATION, from the GIT INDEX. Never a list, and never the filesystem (ersatztv#806).
The filesystem is not an authoritative source: an untracked `.sh` dropped in `.claude/hooks/`
a scratch copy, a half-written hook used to enter this population and be demanded to carry
instrumentation, reddening the suite on that checkout while CI, which never sees the file, stayed
green. That is #778's third shape, and a guard that fails everywhere except where it runs trains
its readers to ignore it.
"""
return tracked_paths(*HOOKS)
"""THE POPULATION, from the filesystem. Never a list."""
return sorted(HOOKS_DIR.glob("*.sh"))
def expected_mode(name: str) -> str:
@@ -80,8 +64,8 @@ def expected_mode(name: str) -> str:
"""
if name in (REPO_ROOT / ".claude" / "settings.json").read_text():
return "capture"
for husky in tracked_paths(*HUSKY):
if name in husky.read_text():
for husky in (REPO_ROOT / ".husky").iterdir():
if husky.is_file() and name in husky.read_text():
return "stream"
return "capture"
@@ -119,7 +103,9 @@ def instrumentation_faults(text: str, name: str) -> list[str]:
if not mode:
faults.append(f"{name}: etv_hook_fire_begin names no stdout mode")
elif mode.group(1) != want:
faults.append(f"{name}: begins in {mode.group(1)!r} mode but its wiring implies {want!r}")
faults.append(
f"{name}: begins in {mode.group(1)!r} mode but its wiring implies {want!r}"
)
# Order is the whole point: 8 of the hooks slurp stdin with `input=$(cat)`, and a begin
# placed after that read would find the pipe already drained — recording a fire with no
@@ -147,9 +133,8 @@ def strip_instrumentation(text: str) -> str:
out.pop()
continue
if skipping:
if line.startswith(
("#", "ETV_HOOK_FIRE_LIB=", "[ -r ", "type etv_hook_fire_begin", "etv_hook_fire_begin ")
):
if line.startswith(("#", "ETV_HOOK_FIRE_LIB=", "[ -r ", "type etv_hook_fire_begin",
"etv_hook_fire_begin ")):
continue
skipping = False
out.append(line)
@@ -166,8 +151,7 @@ def strip_instrumentation(text: str) -> str:
def test_the_population_is_not_empty():
hooks = hook_scripts()
assert len(hooks) >= 10, (
f"only found {len(hooks)} hook scripts tracked under {HOOKS_DIR} — the derivation has "
"stopped matching, "
f"only found {len(hooks)} hook scripts under {HOOKS_DIR} — the glob has stopped matching, "
"so every coverage assertion in this file is vacuous."
)
assert SINK.exists(), "the shared sink is missing; the instrumentation cannot work"
@@ -189,15 +173,8 @@ def test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else():
"""
import difflib
allowed = (
"# ersatztv#776",
"# git hook:",
"# Claude hook:",
"ETV_HOOK_FIRE_LIB=",
'[ -r "$ETV_HOOK_FIRE_LIB"',
"type etv_hook_fire_begin",
"etv_hook_fire_begin ",
)
allowed = ("# ersatztv#776", "# git hook:", "# Claude hook:", "ETV_HOOK_FIRE_LIB=",
"[ -r \"$ETV_HOOK_FIRE_LIB\"", "type etv_hook_fire_begin", "etv_hook_fire_begin ")
for hook in hook_scripts():
cur = hook.read_text().splitlines(keepends=True)
stripped = strip_instrumentation("".join(cur)).splitlines(keepends=True)
@@ -212,6 +189,7 @@ def test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else():
)
def test_the_stripper_actually_strips():
"""The A/B control and the mutation are the same function. If it were a no-op, the differential
test would compare each hook against itself and pass on a wrapper that breaks everything."""
@@ -233,9 +211,8 @@ def test_every_hook_reports_that_it_fired():
for hook in hook_scripts():
faults += instrumentation_faults(hook.read_text(), hook.stem)
assert not faults, (
"these hooks do not report their own execution:\n "
+ "\n ".join(faults)
+ "\n\nAdd the three-line preamble after the `set -` line and BEFORE any stdin read. A hook "
"these hooks do not report their own execution:\n " + "\n ".join(faults) +
"\n\nAdd the three-line preamble after the `set -` line and BEFORE any stdin read. A hook "
"that does not report is one whose firing we can only infer, which is ersatztv#776."
)
@@ -258,7 +235,7 @@ def test_a_hook_that_LOSES_its_instrumentation_is_DETECTED():
def test_begin_placed_AFTER_the_stdin_read_is_DETECTED():
"""The subtler mutation: present but too late. Ordering is the property that makes it work."""
late = 'set -euo pipefail\ninput=$(cat)\nETV_HOOK_FIRE_LIB="x"\n[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true\netv_hook_fire_begin demo "" capture || true\n' # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
late = 'set -euo pipefail\ninput=$(cat)\nETV_HOOK_FIRE_LIB="x"\n[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true\netv_hook_fire_begin demo "" capture || true\n'
faults = instrumentation_faults(late, "demo")
assert any("reads stdin before" in f for f in faults), (
"a begin call placed after `input=$(cat)` was accepted. It would record a fire with no "
@@ -280,15 +257,15 @@ def test_begin_placed_AFTER_the_stdin_read_is_DETECTED():
# issue `deny`. An A/B over silent allows proves transparency on the one path where there is nothing
# to be transparent about.
PAYLOADS = {
"bash-deny": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"ETV_UPDATE_GOLDENS=1 dotnet test"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
"bash-allow": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"ls -la"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
"git-commit": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"git commit -m x"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
"bash-deny": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"ETV_UPDATE_GOLDENS=1 dotnet test"}}',
"bash-allow": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"ls -la"}}',
"git-commit": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"git commit -m x"}}',
"nav-deny": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"mcp__x__navigate","cwd":"%(cwd)s","tool_input":{"url":"http://h/iptv/channels.m3u"}}',
"agent-no-model": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Agent","cwd":"%(cwd)s","tool_input":{"description":"d","prompt":"p"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
"agent-model": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Agent","cwd":"%(cwd)s","tool_input":{"description":"d","prompt":"p","model":"sonnet"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
"merge": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"mcp__gitea__pull_request_write","cwd":"%(cwd)s","tool_input":{"method":"merge","owner":"timothy","repo":"ersatztv","pull_number":1}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
"worktree-add": '{"session_id":"s","hook_event_name":"PostToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"git worktree add /tmp/nope-%(nonce)s HEAD"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
"ui-edit": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Edit","cwd":"%(cwd)s","tool_input":{"file_path":"web/src/screens/Channels.tsx"}}', # noqa: E501 - kept on one line so the fixture is greppable against the payload the hook receives
"agent-no-model": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Agent","cwd":"%(cwd)s","tool_input":{"description":"d","prompt":"p"}}',
"agent-model": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Agent","cwd":"%(cwd)s","tool_input":{"description":"d","prompt":"p","model":"sonnet"}}',
"merge": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"mcp__gitea__pull_request_write","cwd":"%(cwd)s","tool_input":{"method":"merge","owner":"timothy","repo":"ersatztv","pull_number":1}}',
"worktree-add": '{"session_id":"s","hook_event_name":"PostToolUse","tool_name":"Bash","cwd":"%(cwd)s","tool_input":{"command":"git worktree add /tmp/nope-%(nonce)s HEAD"}}',
"ui-edit": '{"session_id":"s","hook_event_name":"PreToolUse","tool_name":"Edit","cwd":"%(cwd)s","tool_input":{"file_path":"web/src/screens/Channels.tsx"}}',
"empty": "",
"garbage": "not json at all",
}
@@ -323,23 +300,17 @@ def sandbox(tmp_path_factory):
def _run(script: Path, payload: str, env: dict, cwd: Path, arg: str | None = None):
cmd = ["bash", str(script)] + ([arg] if arg else [])
p = subprocess.run(cmd, input=payload.encode(), capture_output=True, cwd=str(cwd), env=env, timeout=60)
p = subprocess.run(
cmd, input=payload.encode(), capture_output=True, cwd=str(cwd), env=env, timeout=60
)
return p.returncode, p.stdout
def _git(cwd: Path, *args: str) -> None:
subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@e",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@e",
},
["git", *args], cwd=str(cwd), check=True, capture_output=True,
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@e",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@e"},
)
@@ -357,23 +328,18 @@ def positives(sandbox, tmp_path_factory):
def pay(**kw) -> str:
import json as _json
return _json.dumps({"session_id": "me", "hook_event_name": "PreToolUse", "tool_name": "Bash", **kw})
return _json.dumps({"session_id": "me", "hook_event_name": "PreToolUse",
"tool_name": "Bash", **kw})
# --- worktree-guard: a marker naming ANOTHER session, on a `git commit` -------------------
wt = w / "sibling"
wt.mkdir()
_git(wt, "init", "-q", ".")
(wt / ".claude-worktree-owner").write_text("SOME-OTHER-SESSION\n")
cases["pretooluse-worktree-guard"] = [
(
"foreign-marker-deny",
pay(cwd=str(wt), tool_input={"command": "git commit -m x"}),
None,
base_env,
wt,
)
]
cases["pretooluse-worktree-guard"] = [(
"foreign-marker-deny",
pay(cwd=str(wt), tool_input={"command": "git commit -m x"}), None, base_env, wt,
)]
# --- bom-guard: repo path must match `*ersatztv*`, with a staged BOM-carrying .cs ----------
br = w / "ersatztv-scratch"
@@ -381,15 +347,10 @@ def positives(sandbox, tmp_path_factory):
_git(br, "init", "-q", ".")
(br / "Bad.cs").write_bytes(b"\xef\xbb\xbfclass A {}\n")
_git(br, "add", "Bad.cs")
cases["pretooluse-bom-guard"] = [
(
"staged-bom-deny",
pay(cwd=str(br), tool_input={"command": "git commit -m x"}),
None,
base_env,
br,
)
]
cases["pretooluse-bom-guard"] = [(
"staged-bom-deny",
pay(cwd=str(br), tool_input={"command": "git commit -m x"}), None, base_env, br,
)]
# --- agent-ram: both thresholds, via a stubbed `memory_pressure` ---------------------------
ram_cases = []
@@ -401,15 +362,11 @@ def positives(sandbox, tmp_path_factory):
stub.chmod(0o755)
env = dict(base_env)
env["PATH"] = f"{bindir}:{os.environ['PATH']}"
ram_cases.append(
(
f"free-{pct}pct-{label}",
pay(tool_name="Agent", cwd=str(w), tool_input={"prompt": "p", "model": "sonnet"}),
None,
env,
w,
)
)
ram_cases.append((
f"free-{pct}pct-{label}",
pay(tool_name="Agent", cwd=str(w), tool_input={"prompt": "p", "model": "sonnet"}),
None, env, w,
))
cases["pretooluse-agent-ram"] = ram_cases
# --- decisions-guard: a validator that prints and blocks, and one that prints and passes ---
@@ -452,16 +409,12 @@ def positives(sandbox, tmp_path_factory):
_git(ahead, "add", "-A")
_git(ahead, "commit", "-qm", "advance main")
_git(ahead, "push", "-q", "origin", "main")
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(behind), capture_output=True, text=True).stdout.strip()
cases["prepush-rebase-check"] = [
(
"behind-origin-main-blocks",
f"refs/heads/feat {head} refs/heads/feat {'0' * 40}\n",
None,
base_env,
behind,
)
]
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(behind),
capture_output=True, text=True).stdout.strip()
cases["prepush-rebase-check"] = [(
"behind-origin-main-blocks",
f"refs/heads/feat {head} refs/heads/feat {'0' * 40}\n", None, base_env, behind,
)]
# clean-worktree-check: a file both MODIFIED in the tree and present in the pushed set.
dirty = clone("dirty")
@@ -469,16 +422,13 @@ def positives(sandbox, tmp_path_factory):
_git(dirty, "add", "-A")
_git(dirty, "commit", "-qm", "change file")
(dirty / "file.txt").write_text("uncommitted change\n")
dhead = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(dirty), capture_output=True, text=True).stdout.strip()
cases["prepush-clean-worktree-check"] = [
(
"dirty-file-in-pushed-set-blocks",
f"refs/heads/main {dhead} refs/heads/main {'0' * 40}\n",
None,
base_env,
dirty,
)
]
dhead = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(dirty),
capture_output=True, text=True).stdout.strip()
cases["prepush-clean-worktree-check"] = [(
"dirty-file-in-pushed-set-blocks",
f"refs/heads/main {dhead} refs/heads/main {'0' * 40}\n", None, base_env, dirty,
)]
# --- prepush-donewhen: a push to main closing an issue with an unticked Done-when box -------
#
@@ -493,7 +443,7 @@ def positives(sandbox, tmp_path_factory):
body = _json.dumps({"body": "## Done-when\n\n- [ ] not finished\n- [x] finished\n"}).encode()
class _Stub(http.server.BaseHTTPRequestHandler):
def do_GET(self):
def do_GET(self): # noqa: N802
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
@@ -507,23 +457,20 @@ def positives(sandbox, tmp_path_factory):
threading.Thread(target=srv.serve_forever, daemon=True).start()
dw = clone("donewhen")
(dw / "src.cs").write_text("class A {}\n") # NOT docs-only, so the exemption does not apply
(dw / "src.cs").write_text("class A {}\n") # NOT docs-only, so the exemption does not apply
_git(dw, "add", "-A")
_git(dw, "commit", "-qm", "feat: thing\n\nfixes #999")
dw_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(dw), capture_output=True, text=True).stdout.strip()
dw_prev = subprocess.run(["git", "rev-parse", "HEAD~1"], cwd=str(dw), capture_output=True, text=True).stdout.strip()
dw_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(dw),
capture_output=True, text=True).stdout.strip()
dw_prev = subprocess.run(["git", "rev-parse", "HEAD~1"], cwd=str(dw),
capture_output=True, text=True).stdout.strip()
dw_env = dict(base_env)
dw_env["ETV_GITEA_URL"] = f"http://127.0.0.1:{srv.server_address[1]}"
dw_env["ETV_GITEA_BASICAUTH"] = "stub:stub"
cases["prepush-donewhen"] = [
(
"unticked-donewhen-blocks-push-to-main",
f"refs/heads/main {dw_head} refs/heads/main {dw_prev}\n",
None,
dw_env,
dw,
)
]
cases["prepush-donewhen"] = [(
"unticked-donewhen-blocks-push-to-main",
f"refs/heads/main {dw_head} refs/heads/main {dw_prev}\n", None, dw_env, dw,
)]
return cases
@@ -535,15 +482,10 @@ def _ab_cases(hook: Path, sandbox, positives) -> list[tuple]:
args = [None, "start", "finish"] if hook.stem == "design-sync-reminder" else [None]
for name, template in PAYLOADS.items():
for arg in args:
cases.append(
(
f"matrix:{name}:{arg}",
template % {"cwd": str(root), "nonce": name},
arg,
env,
root,
)
)
cases.append((
f"matrix:{name}:{arg}", template % {"cwd": str(root), "nonce": name},
arg, env, root,
))
for label, payload, arg, penv, cwd in positives.get(hook.stem, []):
cases.append((f"positive:{label}", payload, arg, penv, cwd))
return cases
@@ -571,7 +513,8 @@ def _ab_run(hook: Path, sandbox, case, tag_root: str):
def one(script: Path, t: str):
cmd = ["bash", str(script)] + ([arg] if arg else [])
p = subprocess.run(cmd, input=payload.encode(), capture_output=True, cwd=str(cwd), env=side(t), timeout=90)
p = subprocess.run(cmd, input=payload.encode(), capture_output=True,
cwd=str(cwd), env=side(t), timeout=90)
return p.returncode, p.stdout, p.stderr
return one(control, "control"), one(hook, "instrumented")
@@ -670,16 +613,11 @@ def test_the_worktree_markers_SIDE_EFFECT_is_unchanged(sandbox, tmp_path):
script = tmp_path / "control-marker.sh"
script.write_text(strip_instrumentation(hook.read_text()))
import json as _json
payload = _json.dumps(
{
"session_id": "SESSION-XYZ",
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"cwd": str(repo),
"tool_input": {"command": f"git worktree add {target} HEAD"},
}
)
payload = _json.dumps({
"session_id": "SESSION-XYZ", "hook_event_name": "PostToolUse", "tool_name": "Bash",
"cwd": str(repo),
"tool_input": {"command": f"git worktree add {target} HEAD"},
})
_git(repo, "worktree", "add", "-q", str(target), "HEAD")
_run(script, payload, env, repo)
marker = target / ".claude-worktree-owner"
@@ -703,13 +641,15 @@ def test_stdout_is_replayed_BYTE_EXACT(sandbox):
hook.write_text(
"#!/usr/bin/env bash\nset -euo pipefail\n"
f'. "{SINK}"\n'
'etv_hook_fire_begin trailing "" capture || true\n'
"etv_hook_fire_begin trailing \"\" capture || true\n"
"input=$(cat)\n"
r'printf "{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"}}\n\n"' + "\n"
)
rc, out = _run(hook, '{"tool_name":"Bash"}', env, root)
assert rc == 0
assert out == b'{"hookSpecificOutput":{"permissionDecision":"deny"}}\n\n', f"trailing bytes were altered: {out!r}"
assert out == b'{"hookSpecificOutput":{"permissionDecision":"deny"}}\n\n', (
f"trailing bytes were altered: {out!r}"
)
@pytest.mark.parametrize("code", [0, 1, 2, 3])
@@ -764,12 +704,8 @@ def test_a_TTY_stdin_is_not_slurped(sandbox):
master, slave = pty.openpty()
try:
p = subprocess.Popen(
["bash", str(hook)],
stdin=slave,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
cwd=str(root),
env=env,
["bash", str(hook)], stdin=slave, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, cwd=str(root), env=env,
)
os.close(slave)
try:
@@ -815,11 +751,11 @@ def test_logging_failure_does_not_break_the_hook(sandbox):
@pytest.mark.parametrize(
"emit,mode,expected",
[
(r"{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"}}", "capture", "deny"),
(r"{\"hookSpecificOutput\":{\"permissionDecision\":\"ask\"}}", "capture", "ask"),
(r"{\"hookSpecificOutput\":{\"permissionDecision\":\"allow\"}}", "capture", "allow"),
(r"{\"hookSpecificOutput\":{\"additionalContext\":\"hi\"}}", "capture", "context"),
(r"{\"decision\":\"block\",\"reason\":\"r\"}", "capture", "block"),
(r'{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"}}', "capture", "deny"),
(r'{\"hookSpecificOutput\":{\"permissionDecision\":\"ask\"}}', "capture", "ask"),
(r'{\"hookSpecificOutput\":{\"permissionDecision\":\"allow\"}}', "capture", "allow"),
(r'{\"hookSpecificOutput\":{\"additionalContext\":\"hi\"}}', "capture", "context"),
(r'{\"decision\":\"block\",\"reason\":\"r\"}', "capture", "block"),
("", "capture", "no-op"),
("", "stream", "pass"),
],
@@ -886,10 +822,7 @@ def test_the_report_names_hooks_that_NEVER_fired(sandbox):
)
p = subprocess.run(
["bash", str(SINK), "report", "--all", "--dir", str(logdir)],
capture_output=True,
cwd=str(REPO_ROOT),
env=env,
timeout=60,
capture_output=True, cwd=str(REPO_ROOT), env=env, timeout=60,
)
out = p.stdout.decode()
assert p.returncode == 0, p.stderr.decode()
@@ -926,10 +859,7 @@ def test_the_report_REFUSES_an_empty_population(tmp_path, sandbox):
runenv.pop("CLAUDE_PROJECT_DIR", None)
p = subprocess.run(
["bash", str(copied), "report", "--all", "--dir", str(tmp_path / "log")],
capture_output=True,
cwd=str(tmp_path),
env=runenv,
timeout=60,
capture_output=True, cwd=str(tmp_path), env=runenv, timeout=60,
)
assert p.returncode == 2, (
"the report exited 0 over an empty hook population. It must refuse rather than print a "
@@ -965,11 +895,12 @@ def test_stderr_is_NOT_silenced(sandbox):
'printf "IMPORTANT DIAGNOSTIC\\n" >&2\n'
"exit 1\n"
)
p = subprocess.run(
["bash", str(hook)], input=b'{"tool_name":"Bash"}', capture_output=True, cwd=str(root), env=env, timeout=60
)
p = subprocess.run(["bash", str(hook)], input=b'{"tool_name":"Bash"}',
capture_output=True, cwd=str(root), env=env, timeout=60)
assert p.returncode == 1
assert b"IMPORTANT DIAGNOSTIC" in p.stderr, f"the hook's stderr was swallowed by the instrumentation: {p.stderr!r}"
assert b"IMPORTANT DIAGNOSTIC" in p.stderr, (
f"the hook's stderr was swallowed by the instrumentation: {p.stderr!r}"
)
def test_output_SURVIVES_a_vanished_stdout_tempfile(sandbox):
@@ -992,14 +923,8 @@ def test_output_SURVIVES_a_vanished_stdout_tempfile(sandbox):
)
tmp = root / "vanish-tmp"
tmp.mkdir(exist_ok=True)
p = subprocess.run(
["bash", str(hook)],
input=b'{"tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env={**env, "TMPDIR": str(tmp)},
timeout=60,
)
p = subprocess.run(["bash", str(hook)], input=b'{"tool_name":"Bash"}', capture_output=True,
cwd=str(root), env={**env, "TMPDIR": str(tmp)}, timeout=60)
assert p.returncode == 0
assert b'"permissionDecision":"deny"' in p.stdout, (
"the guard's deny was DISCARDED when its stdout scratch file vanished mid-run. The hook "
@@ -1007,22 +932,24 @@ def test_output_SURVIVES_a_vanished_stdout_tempfile(sandbox):
)
@pytest.mark.parametrize(
"emit,code,expected",
[
# A NON-CANONICAL value is recorded as non-canonical, not laundered into a valid decision.
# This case went both ways before settling: first filed as unclassified prose, then
# lowercased into a clean `deny` (manufacturing a decision the harness may never honour).
(r"{\"permissionDecision\":\"Deny\"}", 0, "unrecognized"),
(r'{\"permissionDecision\":\"Deny\"}', 0, "unrecognized"),
# A non-zero status neither ERASES a printed decision nor annotates it. The status has its
# own field; the decision field states the decision and nothing else.
(r"{\"permissionDecision\":\"deny\"}", 1, "deny"),
(r'{\"permissionDecision\":\"deny\"}', 1, "deny"),
# A failure with nothing classifiable IS an error.
("", 1, "error"),
# Exit 2 is the harness's block channel and DOMINATES the printed JSON. Recording the
# printed `allow` would report a permit for a call that was actually refused — the one
# direction a log of security decisions must never be wrong in.
(r"{\"permissionDecision\":\"allow\"}", 2, "deny-exit2"),
(r'{\"permissionDecision\":\"allow\"}', 2, "deny-exit2"),
],
)
def test_the_classifier_does_not_MISREPORT(sandbox, emit, code, expected):
@@ -1034,16 +961,12 @@ def test_the_classifier_does_not_MISREPORT(sandbox, emit, code, expected):
"#!/usr/bin/env bash\nset -uo pipefail\n"
f'. "{SINK}"\n'
'etv_hook_fire_begin cls "" capture || true\n'
"input=$(cat)\n" + (f'printf "{emit}"\n' if emit else "") + f"exit {code}\n"
)
subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env=runenv,
timeout=60,
"input=$(cat)\n"
+ (f'printf "{emit}"\n' if emit else "")
+ f"exit {code}\n"
)
subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(root), env=runenv, timeout=60)
rec = [ln for ln in (logdir / "s1.jsonl").read_text().splitlines() if '"phase":"exit"' in ln]
assert f'"decision":"{expected}"' in rec[0], f"expected {expected}, got {rec[0]}"
@@ -1060,14 +983,9 @@ def test_the_word_additionalContext_in_PROSE_is_not_a_decision(sandbox):
"input=$(cat)\n"
'printf "note: this hook does not use additionalContext at all\\n"\n'
)
subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=60,
)
subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)}, timeout=60)
rec = [ln for ln in (logdir / "s1.jsonl").read_text().splitlines() if '"phase":"exit"' in ln]
assert '"decision":"output"' in rec[0], f"prose was classified as a decision: {rec[0]}"
@@ -1083,17 +1001,14 @@ def test_the_session_id_cannot_ESCAPE_the_log_directory(sandbox, tmp_path):
logdir = tmp_path / "logs"
hook = tmp_path / "esc.sh"
hook.write_text(
f'#!/usr/bin/env bash\nset -uo pipefail\n. "{SINK}"\netv_hook_fire_begin esc "" capture || true\ninput=$(cat)\n'
"#!/usr/bin/env bash\nset -uo pipefail\n"
f'. "{SINK}"\n'
'etv_hook_fire_begin esc "" capture || true\n'
"input=$(cat)\n"
)
evil = '{"session_id":"../../escaped","tool_name":"Bash"}'
subprocess.run(
["bash", str(hook)],
input=evil.encode(),
capture_output=True,
cwd=str(tmp_path),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=60,
)
subprocess.run(["bash", str(hook)], input=evil.encode(), capture_output=True,
cwd=str(tmp_path), env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)}, timeout=60)
written = list(logdir.glob("*.jsonl"))
assert written, "nothing was logged at all"
for f in written:
@@ -1125,14 +1040,8 @@ def test_the_suite_does_not_write_to_the_PRODUCTION_log(sandbox):
'etv_hook_fire_begin prodcheck "" capture || true\n'
"input=$(cat)\n"
)
subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env=env,
timeout=60,
)
subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(root), env=env, timeout=60)
after = sorted(p.stat().st_mtime_ns for p in default.glob("*.jsonl")) if default.exists() else []
assert before == after, "a test run modified the production hook-fire log"
@@ -1166,19 +1075,18 @@ def test_DELETING_the_replay_makes_the_differential_go_RED(sandbox, positives, t
"input=$(cat)\n"
r'printf "{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"}}"' + "\n"
)
p = subprocess.run(
["bash", str(hook)],
input=b'{"tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env={**env, "TMPDIR": str(tmp_path)},
timeout=60,
)
p = subprocess.run(["bash", str(hook)], input=b'{"tool_name":"Bash"}', capture_output=True,
cwd=str(root), env={**env, "TMPDIR": str(tmp_path)}, timeout=60)
assert p.stdout == b"", (
f"the mutation did not actually disarm the replay, so this proof establishes nothing: stdout={p.stdout!r}"
"the mutation did not actually disarm the replay, so this proof establishes nothing: "
f"stdout={p.stdout!r}"
)
def test_no_exec_in_the_sink_carries_a_STDERR_REDIRECT():
"""The rule that has now been broken twice, enforced instead of restated.
@@ -1215,20 +1123,17 @@ def test_the_log_and_the_REPLAY_agree_when_the_hook_uses_fd_4(sandbox):
f'. "{SINK}"\n'
'etv_hook_fire_begin fd4 "" capture || true\n'
"input=$(cat)\n"
"exec 4</dev/null\n" # the hook takes fd 4 for itself
'exec 4</dev/null\n' # the hook takes fd 4 for itself
r'printf "{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"}}"' + "\n"
)
p = subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=60,
)
p = subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)}, timeout=60)
assert b'"permissionDecision":"deny"' in p.stdout, f"replay lost the decision: {p.stdout!r}"
exits = [r for r in (logdir / "s1.jsonl").read_text().splitlines() if '"phase":"exit"' in r]
assert '"decision":"deny"' in exits[0], f"the harness saw a deny but the log recorded something else: {exits[0]}"
assert '"decision":"deny"' in exits[0], (
f"the harness saw a deny but the log recorded something else: {exits[0]}"
)
def test_an_UNWRITABLE_log_file_is_still_stderr_transparent(sandbox, tmp_path):
@@ -1255,14 +1160,9 @@ def test_an_UNWRITABLE_log_file_is_still_stderr_transparent(sandbox, tmp_path):
"input=$(cat)\n"
r'printf "{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"}}"' + "\n"
)
p = subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(tmp_path),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=60,
)
p = subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(tmp_path),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)}, timeout=60)
assert p.returncode == 0
assert p.stdout == b'{"hookSpecificOutput":{"permissionDecision":"deny"}}'
assert p.stderr == b"", (
@@ -1271,6 +1171,10 @@ def test_an_UNWRITABLE_log_file_is_still_stderr_transparent(sandbox, tmp_path):
)
def test_NUL_bytes_in_hook_output_survive(sandbox):
"""A shell variable cannot hold a NUL, so replaying through `$(...)` silently drops them.
@@ -1288,9 +1192,8 @@ def test_NUL_bytes_in_hook_output_survive(sandbox):
"input=$(cat)\n"
r"printf 'a\000b\n'" + "\n"
)
p = subprocess.run(
["bash", str(hook)], input=b'{"tool_name":"Bash"}', capture_output=True, cwd=str(root), env=env, timeout=60
)
p = subprocess.run(["bash", str(hook)], input=b'{"tool_name":"Bash"}', capture_output=True,
cwd=str(root), env=env, timeout=60)
assert p.stdout == b"a\x00b\n", f"NUL-containing output was mangled: {p.stdout!r}"
assert b"null byte" not in p.stderr, f"a warning leaked to the harness: {p.stderr!r}"
@@ -1299,8 +1202,8 @@ def test_NUL_bytes_in_hook_output_survive(sandbox):
"emit,code,expected",
[
# Every non-canonical value, not just the ones a restricted character class admits.
(r"{\"permissionDecision\":\"deny2\"}", 0, "unrecognized"),
(r"{\"permissionDecision\":\"deny_now\"}", 0, "unrecognized"),
(r'{\"permissionDecision\":\"deny2\"}', 0, "unrecognized"),
(r'{\"permissionDecision\":\"deny_now\"}', 0, "unrecognized"),
# Unclassified output plus a FAILING exit is an error, not `output`: the report histograms
# the decision, so filing it as `output` hid the failure entirely.
("diagnostic text", 1, "error"),
@@ -1314,16 +1217,13 @@ def test_odd_values_and_failing_exits_are_not_LAUNDERED(sandbox, emit, code, exp
"#!/usr/bin/env bash\nset -uo pipefail\n"
f'. "{SINK}"\n'
'etv_hook_fire_begin odd "" capture || true\n'
"input=$(cat)\n" + (f'printf "{emit}"\n' if emit else "") + f"exit {code}\n"
)
subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=60,
"input=$(cat)\n"
+ (f'printf "{emit}"\n' if emit else "")
+ f"exit {code}\n"
)
subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)}, timeout=60)
rec = [r for r in (logdir / "s1.jsonl").read_text().splitlines() if '"phase":"exit"' in r]
assert f'"decision":"{expected}"' in rec[0], f"expected {expected}, got {rec[0]}"
@@ -1347,12 +1247,9 @@ def test_an_INHERITED_flushed_flag_does_not_disable_reporting(sandbox):
r'printf "{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"}}"' + "\n"
)
p = subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}', capture_output=True,
cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir), "ETV_HOOK_FIRE_FLUSHED": "1"},
timeout=60,
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir), "ETV_HOOK_FIRE_FLUSHED": "1"}, timeout=60,
)
assert p.stdout == b'{"hookSpecificOutput":{"permissionDecision":"deny"}}', (
f"an inherited FLUSHED=1 left stdout redirected and swallowed the decision: {p.stdout!r}"
@@ -1387,7 +1284,9 @@ def test_a_SIGNALLED_hook_behaves_EXACTLY_as_an_uninstrumented_one(sandbox, sign
)
instrumented = root / f"sigab-{signame}.sh"
instrumented.write_text(
f'#!/usr/bin/env bash\nset -uo pipefail\n. "{SINK}"\netv_hook_fire_begin sigab "" stream || true\n' + body
"#!/usr/bin/env bash\nset -uo pipefail\n"
f'. "{SINK}"\n'
f'etv_hook_fire_begin sigab "" stream || true\n' + body
)
control = root / f"sigab-control-{signame}.sh"
control.write_text("#!/usr/bin/env bash\nset -uo pipefail\n" + body)
@@ -1403,15 +1302,9 @@ def test_a_SIGNALLED_hook_behaves_EXACTLY_as_an_uninstrumented_one(sandbox, sign
# anything. Measured: pid-only gives control 4.0s and trapped 4.1s; group signalling
# gives control 0.002s and trapped 0.054s. A supervisor kills the group, so this is
# also the shape that actually occurs.
p = subprocess.Popen(
["bash", str(script)],
stdin=fh,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(root),
env=env,
start_new_session=True,
)
p = subprocess.Popen(["bash", str(script)], stdin=fh, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, cwd=str(root), env=env,
start_new_session=True)
time.sleep(1.0)
t0 = time.monotonic()
os.killpg(os.getpgid(p.pid), sig)
@@ -1419,8 +1312,7 @@ def test_a_SIGNALLED_hook_behaves_EXACTLY_as_an_uninstrumented_one(sandbox, sign
results[tag] = (p.returncode, time.monotonic() - t0, out, err)
(rc_a, dt_a, out_a, err_a), (rc_b, dt_b, out_b, err_b) = (
results["control"],
results["instrumented"],
results["control"], results["instrumented"],
)
assert out_a == out_b, f"SIG{signame}: stdout differs under signal: {out_a!r} vs {out_b!r}"
@@ -1437,11 +1329,13 @@ def test_a_SIGNALLED_hook_behaves_EXACTLY_as_an_uninstrumented_one(sandbox, sign
# require one of bash's four signal words followed by `:` or whitespace, so an ordinary
# diagnostic still fails the comparison.
return b"\n".join(
ln for ln in e.split(b"\n") if not re.search(rb"(^|\s)(Terminated|Hangup|Interrupt|Killed)(:|\s|$)", ln)
ln for ln in e.split(b"\n")
if not re.search(rb"(^|\s)(Terminated|Hangup|Interrupt|Killed)(:|\s|$)", ln)
)
assert strip_jobnotice(err_a) == strip_jobnotice(err_b), (
f"SIG{signame}: stderr differs under signal beyond bash's job-control notice: {err_a!r} vs {err_b!r}"
f"SIG{signame}: stderr differs under signal beyond bash's job-control notice: "
f"{err_a!r} vs {err_b!r}"
)
assert rc_a == rc_b, (
f"SIG{signame}: exit status differs, control={rc_a} instrumented={rc_b}. git and the "
@@ -1454,15 +1348,14 @@ def test_a_SIGNALLED_hook_behaves_EXACTLY_as_an_uninstrumented_one(sandbox, sign
)
@pytest.mark.parametrize(
"locale_env",
[
{"LANG": "en_US.UTF-8"},
{"LC_CTYPE": "en_US.UTF-8"},
{"LC_CTYPE": "UTF-8"}, # macOS Terminal's default, and the case that broke the fix
{"LC_ALL": "en_US.UTF-8"},
],
)
@pytest.mark.parametrize("locale_env", [
{"LANG": "en_US.UTF-8"},
{"LC_CTYPE": "en_US.UTF-8"},
{"LC_CTYPE": "UTF-8"}, # macOS Terminal's default, and the case that broke the fix
{"LC_ALL": "en_US.UTF-8"},
])
def test_an_INVALID_UTF8_byte_in_a_decision_is_still_classified(tmp_path, locale_env):
"""`local LC_ALL=C` does not export, so the child `sed`/`tr` never saw it — the fix was INERT.
@@ -1494,15 +1387,10 @@ def test_an_INVALID_UTF8_byte_in_a_decision_is_still_classified(tmp_path, locale
'printf \'{"hookSpecificOutput":{"permissionDecision":"deny",'
'"permissionDecisionReason":"caf\\xe9"}}\'\n'
)
env = {"PATH": os.environ["PATH"], "HOME": os.environ["HOME"], "ETV_HOOK_FIRE_LOG_DIR": str(logdir), **locale_env}
p = subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(tmp_path),
env=env,
timeout=60,
)
env = {"PATH": os.environ["PATH"], "HOME": os.environ["HOME"],
"ETV_HOOK_FIRE_LOG_DIR": str(logdir), **locale_env}
p = subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(tmp_path), env=env, timeout=60)
assert p.stderr == b"", f"{locale_env}: a locale diagnostic leaked to the harness: {p.stderr!r}"
record = (logdir / "s1.jsonl").read_text()
@@ -1531,24 +1419,16 @@ def test_a_NESTED_identity_field_does_not_outrank_the_TOP_LEVEL_one(tmp_path):
"input=$(cat)\n"
)
import json as _json
payload = _json.dumps(
{
"session_id": "TOP-SESSION",
"hook_event_name": "PreToolUse",
"tool_name": "TopTool",
"tool_input": {"filler": "x" * 1000},
"tool_response": {"session_id": "NESTED-SESSION", "tool_name": "NestedTool"},
}
)
subprocess.run(
["bash", str(hook)],
input=payload.encode(),
capture_output=True,
cwd=str(tmp_path),
env={**os.environ, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=60,
)
payload = _json.dumps({
"session_id": "TOP-SESSION",
"hook_event_name": "PreToolUse",
"tool_name": "TopTool",
"tool_input": {"filler": "x" * 1000},
"tool_response": {"session_id": "NESTED-SESSION", "tool_name": "NestedTool"},
})
subprocess.run(["bash", str(hook)], input=payload.encode(), capture_output=True,
cwd=str(tmp_path), env={**os.environ, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=60)
assert (logdir / "TOP-SESSION.jsonl").exists(), (
f"records filed under the wrong session: {[f.name for f in logdir.glob('*.jsonl')]}"
@@ -1571,14 +1451,9 @@ def test_a_PRESENT_but_empty_decision_is_not_laundered(sandbox):
"input=$(cat)\n"
r'printf "{\"hookSpecificOutput\":{\"permissionDecision\":\"\"}}"' + "\n"
)
subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=60,
)
subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(root),
env={**env, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)}, timeout=60)
rec = [r for r in (logdir / "s1.jsonl").read_text().splitlines() if '"phase":"exit"' in r]
assert '"decision":"unrecognized"' in rec[0], f"an empty decision was laundered: {rec[0]}"
@@ -1598,25 +1473,21 @@ def test_identity_fields_BEYOND_the_fast_path_cap_are_still_found(tmp_path):
hook = tmp_path / "big.sh"
hook.write_text(
f'#!/usr/bin/env bash\nset -uo pipefail\n. "{SINK}"\netv_hook_fire_begin big "" capture || true\ninput=$(cat)\n'
"#!/usr/bin/env bash\nset -uo pipefail\n"
f'. "{SINK}"\n'
'etv_hook_fire_begin big "" capture || true\n'
"input=$(cat)\n"
)
logdir = tmp_path / "biglog"
payload = _json.dumps(
{
"tool_input": {"filler": "x" * 400_000},
"session_id": "TOP-SESSION",
"hook_event_name": "PreToolUse",
"tool_name": "TopTool",
}
)
subprocess.run(
["bash", str(hook)],
input=payload.encode(),
capture_output=True,
cwd=str(tmp_path),
env={**os.environ, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=90,
)
payload = _json.dumps({
"tool_input": {"filler": "x" * 400_000},
"session_id": "TOP-SESSION",
"hook_event_name": "PreToolUse",
"tool_name": "TopTool",
})
subprocess.run(["bash", str(hook)], input=payload.encode(), capture_output=True,
cwd=str(tmp_path), env={**os.environ, "ETV_HOOK_FIRE_LOG_DIR": str(logdir)},
timeout=90)
assert (logdir / "TOP-SESSION.jsonl").exists(), (
"identity beyond the fast-path cap was not found, so the fire filed under "
@@ -1639,17 +1510,11 @@ def test_the_field_helper_does_not_LEAK_into_the_hooks_namespace(sandbox):
f'. "{SINK}"\n'
'etv_hook_fire_begin ns "" capture || true\n'
"input=$(cat)\n"
"if declare -F _etv_field >/dev/null 2>&1; then echo LEAKED_etv_field; fi\n"
"if declare -F etv_hook_fire__field >/dev/null 2>&1; then echo LEAKED_namespaced; fi\n"
'if declare -F _etv_field >/dev/null 2>&1; then echo LEAKED_etv_field; fi\n'
'if declare -F etv_hook_fire__field >/dev/null 2>&1; then echo LEAKED_namespaced; fi\n'
"echo CLEAN\n"
)
p = subprocess.run(
["bash", str(hook)],
input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True,
cwd=str(root),
env=env,
timeout=60,
)
p = subprocess.run(["bash", str(hook)], input=b'{"session_id":"s1","tool_name":"Bash"}',
capture_output=True, cwd=str(root), env=env, timeout=60)
assert b"LEAKED" not in p.stdout, f"a helper leaked into the hook's namespace: {p.stdout!r}"
assert b"CLEAN" in p.stdout
+58 -72
View File
@@ -51,12 +51,12 @@ def preflight(tmp_path):
so every test passed.
"""
shim = bindir / "jq"
body = '#!/bin/sh\nif [ "$1" = "--version" ]; then\n'
body = "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n"
if version_line:
body += f' printf "%s\\n" {_shq(version_line)}\n'
body += ' printf "%%s\\n" %s\n' % _shq(version_line)
if stderr:
body += f' printf "%s\\n" {_shq(stderr)} >&2\n'
body += f" exit {exit_code}\nfi\nexit 0\n"
body += ' printf "%%s\\n" %s >&2\n' % _shq(stderr)
body += " exit %d\nfi\nexit 0\n" % exit_code
shim.write_text(body)
shim.chmod(0o755)
@@ -73,7 +73,8 @@ def preflight(tmp_path):
# but jq itself (`command -v` is a builtin, and bash is invoked by absolute path), so
# there is nothing to keep.
env["PATH"] = str(bindir)
return subprocess.run([BASH, str(SCRIPT), *args], env=env, capture_output=True, text=True)
return subprocess.run([BASH, str(SCRIPT), *args],
env=env, capture_output=True, text=True)
def run_bytes(self, *args):
"""Same, but WITHOUT text mode.
@@ -86,7 +87,8 @@ def preflight(tmp_path):
"""
env = dict(os.environ)
env["PATH"] = str(bindir)
return subprocess.run([BASH, str(SCRIPT), *args], env=env, capture_output=True)
return subprocess.run([BASH, str(SCRIPT), *args],
env=env, capture_output=True)
return Handle()
@@ -170,15 +172,11 @@ def test_expect_without_a_value_is_a_usage_error_WITH_output(preflight):
# the script exited 0 having asserted NOTHING. That is this script's own stated failure mode,
# reproduced inside itself, which is why these cases are pinned rather than left to inspection.
@pytest.mark.parametrize(
"version_line",
[
"jq version 1.6", # some distro wrappers print this form
"JQ-1.6",
"jq-1.6-dirty",
],
)
@pytest.mark.parametrize("version_line", [
"jq version 1.6", # some distro wrappers print this form
"JQ-1.6",
"jq-1.6-dirty",
])
def test_unusual_but_parseable_version_forms_are_accepted(preflight, version_line):
preflight.with_jq(version_line)
r = preflight.run()
@@ -192,8 +190,7 @@ def test_unparseable_version_fails_CLOSED_rather_than_asserting_nothing(prefligh
r = preflight.run()
assert r.returncode == 1, (
f"{version_line!r} exited {r.returncode}: an unparsed version must never reach — or "
"silently skip — the floor assertion"
)
"silently skip — the floor assertion")
assert "could not parse" in r.stderr
@@ -205,21 +202,20 @@ def test_a_jq_that_cannot_START_fails_closed(preflight):
discarding the exit status, so that message became the parse input, `2.34` matched, and the floor
was certified green on a jq that cannot run at all.
"""
preflight.with_jq("", stderr="jq: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found", exit_code=127)
preflight.with_jq(
"", stderr="jq: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found",
exit_code=127)
r = preflight.run()
assert r.returncode == 1
assert "cannot run" in r.stderr
assert "parsed 2.34" not in r.stdout, "stderr must never be parsed as a version"
@pytest.mark.parametrize(
"version_line",
[
"warning: something 3.14", # a noise line carrying a plausible number
"2026.07.26 jq-1.6", # a date prefix, which outranks the real version if unanchored
"jq-master-v0.0.0-1.6",
],
)
@pytest.mark.parametrize("version_line", [
"warning: something 3.14", # a noise line carrying a plausible number
"2026.07.26 jq-1.6", # a date prefix, which outranks the real version if unanchored
"jq-master-v0.0.0-1.6",
])
def test_a_number_that_is_not_the_VERSION_is_not_accepted_as_one(preflight, version_line):
"""Matching the first `<digits>.<digits>` ANYWHERE let a prefix win over the real version.
`2026.07.26 jq-1.6` parsed as 2026.07 and sailed over the floor. The pattern is anchored to the
@@ -230,13 +226,10 @@ def test_a_number_that_is_not_the_VERSION_is_not_accepted_as_one(preflight, vers
assert "could not parse" in r.stderr
@pytest.mark.parametrize(
"version_line",
[
"jq-99999999999999999999999.0",
"jq-1.99999999999999999999999",
],
)
@pytest.mark.parametrize("version_line", [
"jq-99999999999999999999999.0",
"jq-1.99999999999999999999999",
])
def test_an_OUT_OF_RANGE_digit_run_fails_closed(preflight, version_line):
"""The round-1 fail-open mechanism, resurrected via an over-long number.
@@ -254,28 +247,25 @@ def test_an_OUT_OF_RANGE_digit_run_fails_closed(preflight, version_line):
assert r.returncode == 1, f"{version_line!r} exited 0 — the floor was not asserted"
@pytest.mark.parametrize(
"version_line",
[
# Killed by the SEPARATOR restriction (a blank separator must be followed by `version`).
"jq\n2.34: cannot load shared library",
"jq\n\n\n99.9",
"jq -- 2.34 (real jq-1.6)",
"jq\t\t9.9",
# Killed ONLY by the first-line slice + `[[:blank:]]`. These carry the literal word `version`,
# so the separator restriction is satisfied and cannot save us — the newline must be excluded
# from the separator class AND the parse confined to line one.
#
# Without these, a round-5 mutation check found that reverting BOTH of those changes together
# (`[[:blank:]]`→`[[:space:]]` and parsing `$raw` instead of `$first`) left the whole suite
# GREEN: the four cases above are all killed by the separator alone, so they attributed the fix
# to the wrong layer. A test that passes for the wrong reason is how the previous three rounds
# each shipped a defect.
"jq\nversion\n9.9",
"jq\nversion 9.9",
"jq \n version \n 9.9",
],
)
@pytest.mark.parametrize("version_line", [
# Killed by the SEPARATOR restriction (a blank separator must be followed by `version`).
"jq\n2.34: cannot load shared library",
"jq\n\n\n99.9",
"jq -- 2.34 (real jq-1.6)",
"jq\t\t9.9",
# Killed ONLY by the first-line slice + `[[:blank:]]`. These carry the literal word `version`,
# so the separator restriction is satisfied and cannot save us — the newline must be excluded
# from the separator class AND the parse confined to line one.
#
# Without these, a round-5 mutation check found that reverting BOTH of those changes together
# (`[[:blank:]]`→`[[:space:]]` and parsing `$raw` instead of `$first`) left the whole suite
# GREEN: the four cases above are all killed by the separator alone, so they attributed the fix
# to the wrong layer. A test that passes for the wrong reason is how the previous three rounds
# each shipped a defect.
"jq\nversion\n9.9",
"jq\nversion 9.9",
"jq \n version \n 9.9",
])
def test_a_number_AFTER_the_jq_token_is_not_reachable_across_filler(preflight, version_line):
"""Two independent layers keep a stray number from being read as the version, and both are
pinned here: the separator must be one of the forms real jq emits (`jq-1.6` / `jq version 1.6`),
@@ -316,17 +306,14 @@ def test_the_observability_line_stays_on_ONE_line(preflight):
assert "trailing noise" not in r.stdout
@pytest.mark.parametrize(
"version_line,expected",
[
("jq-1.6 (Debian 1.6-2.1)", "1.6"), # distro packaging suffix
("jq-1.10", "1.10"), # two-digit minor: must compare numerically, not lexically
("jq-1.7.1", "1.7"),
("jq-1.6.0", "1.6"),
("jq-v1.6", "1.6"),
("JQ-1.6", "1.6"),
],
)
@pytest.mark.parametrize("version_line,expected", [
("jq-1.6 (Debian 1.6-2.1)", "1.6"), # distro packaging suffix
("jq-1.10", "1.10"), # two-digit minor: must compare numerically, not lexically
("jq-1.7.1", "1.7"),
("jq-1.6.0", "1.6"),
("jq-v1.6", "1.6"),
("JQ-1.6", "1.6"),
])
def test_legitimate_forms_still_parse_to_the_right_version(preflight, version_line, expected):
preflight.with_jq(version_line)
r = preflight.run()
@@ -336,7 +323,6 @@ def test_legitimate_forms_still_parse_to_the_right_version(preflight, version_li
# --- Wiring guards: the preflight is worthless if a caller silently stops running it ------------
def test_script_tests_pins_the_jq_version():
"""The pin is the tripwire, so its presence is asserted rather than merely commented.
@@ -347,7 +333,8 @@ def test_script_tests_pins_the_jq_version():
is `main`, which does not yet contain `scripts/jq-preflight.sh`.
"""
pr_checks = (WORKFLOWS / "pr-checks.yml").read_text()
assert "jq-preflight.sh --expect" in pr_checks, "script-tests must pin the jq version — that pin is the tripwire"
assert "jq-preflight.sh --expect" in pr_checks, \
"script-tests must pin the jq version — that pin is the tripwire"
def test_review_verdict_never_pins_a_jq_version():
@@ -357,7 +344,6 @@ def test_review_verdict_never_pins_a_jq_version():
follow-up PR adds the floor-only call rather than being a comment someone can miss.
"""
review_verdict = (WORKFLOWS / "review-verdict.yml").read_text()
assert "jq-preflight.sh --expect" not in review_verdict, (
"review-verdict.yml must NOT pin a jq version: it writes the required review-verdict/h10 "
"status, so a pin would deadlock every merge on a jq bump (ersatztv#648)"
)
assert "jq-preflight.sh --expect" not in review_verdict, \
("review-verdict.yml must NOT pin a jq version: it writes the required review-verdict/h10 "
"status, so a pin would deadlock every merge on a jq bump (ersatztv#648)")
+21 -26
View File
@@ -33,7 +33,7 @@ SHA = "a9e3e23abf337980ca4c05854f5b1e210099d08b"
# The PR is deliberately NOT docs-only: the docs-only exemption short-circuits the whole gate, so a
# docs PR would never reach the base check and the tests would pass without exercising it.
CURL_SHIM = r"""#!/usr/bin/env python3
CURL_SHIM = r'''#!/usr/bin/env python3
import json, os, sys, pathlib, urllib.parse
state = pathlib.Path(os.environ["STUB_DIR"])
@@ -75,18 +75,14 @@ if "/pulls/" in url:
sys.exit(0)
print("{}")
"""
'''
@pytest.fixture
def hook(tmp_path):
bindir = tmp_path / "bin"
bindir.mkdir()
curl = bindir / "curl"
curl.write_text(CURL_SHIM)
curl.chmod(0o755)
state = tmp_path / "state"
state.mkdir()
bindir = tmp_path / "bin"; bindir.mkdir()
curl = bindir / "curl"; curl.write_text(CURL_SHIM); curl.chmod(0o755)
state = tmp_path / "state"; state.mkdir()
(state / "live_base").write_text("main")
(state / "verdict_desc").write_text("Review-verdict: MERGEABLE @ a9e3e23 (base: main)")
@@ -94,7 +90,7 @@ def hook(tmp_path):
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["STUB_SHA"] = SHA
env["ETV_GITEA_TOKEN"] = "stub" # noqa: S105 - deliberately fake; the real credential comes from the environment
env["ETV_GITEA_TOKEN"] = "stub"
env["ETV_GITEA_URL"] = "http://gitea.example"
env.pop("ETV_GITEA_BASICAUTH", None)
@@ -107,8 +103,10 @@ def hook(tmp_path):
(state / "verdict_desc").write_text(desc)
def decision(self):
payload = {"tool_input": {"method": "merge", "owner": "timothy", "repo": "ersatztv", "pull_number": 42}}
r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), env=env, capture_output=True, text=True)
payload = {"tool_input": {"method": "merge", "owner": "timothy",
"repo": "ersatztv", "pull_number": 42}}
r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
env=env, capture_output=True, text=True)
assert r.returncode == 0, r.stderr
if not r.stdout.strip():
return None
@@ -126,24 +124,21 @@ def test_a_retargeted_base_denies_a_verdict_formed_against_the_old_one(hook):
reason = hook.reason()
assert "deny" in reason, "a verdict formed against a different base was allowed to stand"
assert "release/26.4" in reason and "main" in reason, (
"the deny must name both bases; a reader cannot act on 'the base changed'"
)
"the deny must name both bases; a reader cannot act on 'the base changed'")
def test_positive_control_an_unchanged_base_does_not_trigger_the_base_deny(hook):
"""Without this, the test above could pass because the hook denies on every path — which it
very nearly does, since this PR is non-docs and the rest of the gate is unstubbed."""
reason = hook.reason()
assert "ersatztv#632" not in reason, "the base check fired on a PR whose base never moved"
assert "ersatztv#632" not in reason, (
"the base check fired on a PR whose base never moved")
@pytest.mark.parametrize(
"desc",
[
"Review-verdict: MERGEABLE @ a9e3e23", # posted before #632
"NONE", # no verdict status on this head at all
],
)
@pytest.mark.parametrize("desc", [
"Review-verdict: MERGEABLE @ a9e3e23", # posted before #632
"NONE", # no verdict status on this head at all
])
def test_a_verdict_with_no_recorded_base_gets_no_opinion(hook, desc):
"""Graceful adoption. Denying here would block every in-flight PR the day this lands, and the
window closes on its own: verdicts are per-head and short-lived, so every verdict posted after
@@ -156,8 +151,7 @@ def test_a_verdict_with_no_recorded_base_gets_no_opinion(hook, desc):
hook.set_live_base("release/26.4")
hook.set_verdict_description(desc)
assert "base" not in hook.reason(), (
"a pre-#632 verdict drew a base-related decision for a field it could not have carried"
)
"a pre-#632 verdict drew a base-related decision for a field it could not have carried")
@pytest.mark.parametrize("failure", ["SCALAR-ROW", "NONSTRING-DESC"])
@@ -179,7 +173,7 @@ def test_a_malformed_status_MEMBER_asks_too(hook, failure):
@pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE"])
def test_an_UNREADABLE_status_response_asks_rather_than_skipping_the_check(hook, failure):
""" "Could not check" is a third outcome, not a quiet synonym for "no base recorded".
""""Could not check" is a third outcome, not a quiet synonym for "no base recorded".
The first draft collapsed the two: an unreadable status response produced an empty
`recorded_base`, took the graceful-adoption path, and skipped validation in silence after
@@ -208,4 +202,5 @@ def test_the_comparator_is_the_base_REF_not_its_tip_sha():
A base branch that merely ADVANCES must be silent here; rebasing onto it moves the head sha,
which the per-sha binding already covers."""
assert ".base.ref" in HOOK.read_text(), "the hook must compare the base BRANCH, not its tip sha"
assert ".base.sha" not in HOOK.read_text(), "comparing base.sha deadlocks every open PR whenever main advances"
assert ".base.sha" not in HOOK.read_text(), (
"comparing base.sha deadlocks every open PR whenever main advances")
+52 -64
View File
@@ -33,7 +33,7 @@ HOOK = REPO_ROOT / ".claude" / "hooks" / "pretooluse-merge-consent.sh"
SHA = "a9e3e23abf337980ca4c05854f5b1e210099d08b"
# Serves paged `pulls/N/files`, plus the minimal PR object the hook reads first.
CURL_SHIM = r"""#!/usr/bin/env python3
CURL_SHIM = r'''#!/usr/bin/env python3
import json, os, sys, pathlib, urllib.parse
state = pathlib.Path(os.environ["STUB_DIR"])
@@ -88,7 +88,7 @@ if "/pulls/" in url:
sys.exit(0)
print("{}")
"""
'''
def _rows(paths):
@@ -97,19 +97,15 @@ def _rows(paths):
@pytest.fixture
def hook(tmp_path):
bindir = tmp_path / "bin"
bindir.mkdir()
shim = bindir / "curl"
shim.write_text(CURL_SHIM)
shim.chmod(0o755)
state = tmp_path / "state"
state.mkdir()
bindir = tmp_path / "bin"; bindir.mkdir()
shim = bindir / "curl"; shim.write_text(CURL_SHIM); shim.chmod(0o755)
state = tmp_path / "state"; state.mkdir()
env = dict(os.environ)
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["STUB_SHA"] = SHA
env["ETV_GITEA_TOKEN"] = "stub" # noqa: S105 - deliberately fake; the real credential comes from the environment
env["ETV_GITEA_TOKEN"] = "stub"
env["ETV_GITEA_URL"] = "http://gitea.example"
env.pop("ETV_GITEA_BASICAUTH", None)
@@ -118,10 +114,10 @@ def hook(tmp_path):
(state / "pages.json").write_text(json.dumps(list(pages)))
def run(self):
payload = {"tool_input": {"method": "merge", "owner": "timothy", "repo": "ersatztv", "pull_number": 42}}
return subprocess.run(
["bash", str(HOOK)], input=json.dumps(payload), env=env, capture_output=True, text=True
)
payload = {"tool_input": {"method": "merge", "owner": "timothy",
"repo": "ersatztv", "pull_number": 42}}
return subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
env=env, capture_output=True, text=True)
def exempted(self):
"""Exempt == passthrough == exit 0 with no decision JSON."""
@@ -144,14 +140,16 @@ def test_code_pr_is_not_exempt(hook):
def test_protected_path_on_a_LATER_page_is_still_seen(hook):
"""The #619 shape: 50 docs files on page 1, code hiding on page 2."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), _rows(["scripts/decisions_lib.py"]))
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
_rows(["scripts/decisions_lib.py"]))
assert hook.exempted() is False
def test_full_first_page_alone_does_not_end_enumeration(hook):
"""A full 50-row page must trigger a second fetch, not terminate the loop."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), _rows(["docs/tail.md"]))
assert hook.exempted() is True # genuinely all docs, but only provable by reading page 2
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
_rows(["docs/tail.md"]))
assert hook.exempted() is True # genuinely all docs, but only provable by reading page 2
def test_rename_of_code_into_docs_is_not_exempt(hook):
@@ -163,15 +161,15 @@ def test_rename_of_code_into_docs_is_not_exempt(hook):
exempt. That is the hook's contract and differs from `review-verdict.yml`'s stricter
PROTECTED list, which must never auto-post a green status for those paths.
"""
hook.set_pages(
[{"filename": "docs/innocuous-note.md", "status": "renamed", "previous_filename": "ErsatzTV/Program.cs"}]
)
hook.set_pages([{"filename": "docs/innocuous-note.md", "status": "renamed",
"previous_filename": "ErsatzTV/Program.cs"}])
assert hook.exempted() is False
def test_rename_between_two_exempt_paths_stays_exempt(hook):
"""Guards the above: reading previous_filename must not over-trigger on legitimate moves."""
hook.set_pages([{"filename": "docs/b.md", "status": "renamed", "previous_filename": "docs/a.md"}])
hook.set_pages([{"filename": "docs/b.md", "status": "renamed",
"previous_filename": "docs/a.md"}])
assert hook.exempted() is True
@@ -234,15 +232,14 @@ def test_malformed_rename_row_withholds_the_exemption(hook):
Real Gitea always populates it (verified by constructing a rename), so this is the
malformed-2xx class the guard claims to fail closed on; the claim should match the behaviour.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), [{"filename": "docs/moved.md", "status": "renamed"}])
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
[{"filename": "docs/moved.md", "status": "renamed"}])
assert hook.exempted() is False
def test_rename_row_with_empty_previous_filename_withholds_the_exemption(hook):
hook.set_pages(
_rows([f"docs/f{i}.md" for i in range(50)]),
[{"filename": "docs/moved.md", "status": "renamed", "previous_filename": ""}],
)
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
[{"filename": "docs/moved.md", "status": "renamed", "previous_filename": ""}])
assert hook.exempted() is False
@@ -257,13 +254,9 @@ def test_ordinary_row_without_previous_filename_is_still_valid(hook):
`test_a_rename_disguised_by_an_unknown_status_is_rejected[None]`. The statusless row this test
used to carry was incidental to what it is actually pinning.
"""
hook.set_pages(
[
{"filename": "docs/a.md", "status": "modified"},
{"filename": "docs/b.md", "status": "added"},
{"filename": "docs/c.md", "status": "changed"},
]
)
hook.set_pages([{"filename": "docs/a.md", "status": "modified"},
{"filename": "docs/b.md", "status": "added"},
{"filename": "docs/c.md", "status": "changed"}])
assert hook.exempted() is True
@@ -319,20 +312,16 @@ sys.exit(127)
@pytest.fixture
def hook_jq16(tmp_path):
"""Same harness as `hook`, plus a jq shim emulating jq 1.6's empty-input exit status."""
bindir = tmp_path / "bin"
bindir.mkdir()
(bindir / "curl").write_text(CURL_SHIM)
(bindir / "curl").chmod(0o755)
(bindir / "jq").write_text(_JQ16_SHIM)
(bindir / "jq").chmod(0o755)
state = tmp_path / "state"
state.mkdir()
bindir = tmp_path / "bin"; bindir.mkdir()
(bindir / "curl").write_text(CURL_SHIM); (bindir / "curl").chmod(0o755)
(bindir / "jq").write_text(_JQ16_SHIM); (bindir / "jq").chmod(0o755)
state = tmp_path / "state"; state.mkdir()
env = dict(os.environ)
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["STUB_SHA"] = SHA
env["ETV_GITEA_TOKEN"] = "stub" # noqa: S105 - deliberately fake; the real credential comes from the environment
env["ETV_GITEA_TOKEN"] = "stub"
env["ETV_GITEA_URL"] = "http://gitea.example"
env.pop("ETV_GITEA_BASICAUTH", None)
@@ -341,8 +330,10 @@ def hook_jq16(tmp_path):
(state / "pages.json").write_text(json.dumps(list(pages)))
def exempted(self):
payload = {"tool_input": {"method": "merge", "owner": "timothy", "repo": "ersatztv", "pull_number": 42}}
r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), env=env, capture_output=True, text=True)
payload = {"tool_input": {"method": "merge", "owner": "timothy",
"repo": "ersatztv", "pull_number": 42}}
r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
env=env, capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return r.stdout.strip() == ""
@@ -403,9 +394,8 @@ def test_newline_in_previous_filename_is_also_rejected(hook):
rejects on its own merits, so the test passed with the newline guard entirely removed it
asserted the outcome without ever exercising the mechanism. That is the same
filter-hides-the-defect trap the guard itself is about."""
hook.set_pages(
[{"filename": "docs/ok.md", "previous_filename": "safe.md\ndocs/Program.cs", "status": "renamed"}], []
)
hook.set_pages([{"filename": "docs/ok.md", "previous_filename": "safe.md\ndocs/Program.cs",
"status": "renamed"}], [])
assert hook.exempted() is False
@@ -416,7 +406,8 @@ def test_previous_filename_is_validated_on_NON_renamed_rows_too(hook, status):
`chunk` emits `(.previous_filename // empty)` for EVERY row regardless of `.status`, but the
field was validated only when `.status == "renamed"`. A row marked `modified` (or Gitea's
distinct `copied`) carrying a newline in `previous_filename` was reproducibly exempted."""
hook.set_pages([{"filename": "docs/ok.md", "status": status, "previous_filename": "safe.md\ndocs/Program.cs"}], [])
hook.set_pages([{"filename": "docs/ok.md", "status": status,
"previous_filename": "safe.md\ndocs/Program.cs"}], [])
assert hook.exempted() is False
@@ -430,18 +421,21 @@ def test_dotdot_path_component_is_rejected(hook):
def test_legitimate_rename_within_docs_still_exempts(hook):
"""Positive control: the tightened row schema must not break a real docs-only rename."""
hook.set_pages([{"filename": "docs/b.md", "status": "renamed", "previous_filename": "docs/a.md"}], [])
hook.set_pages([{"filename": "docs/b.md", "status": "renamed",
"previous_filename": "docs/a.md"}], [])
assert hook.exempted() is True
def test_short_NONTERMINAL_page_does_not_end_the_enumeration(hook):
""" "Fewer rows than we asked for" must not be read as "last page".
""""Fewer rows than we asked for" must not be read as "last page".
Gitea caps `limit` at the server-wide MAX_RESPONSE_ITEMS (default 50, configurable) and may
return fewer rows than requested. A 30-row docs page followed by a page of code would otherwise
complete the enumeration over a PARTIAL list the same fail-open, reached with no transport
error at all. Only a validated EMPTY page may terminate it."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(30)]), _rows(["ErsatzTV/Program.cs"]), [])
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(30)]),
_rows(["ErsatzTV/Program.cs"]),
[])
assert hook.exempted() is False
@@ -479,14 +473,9 @@ def test_gitea_real_status_values_are_accepted(hook):
"""Positive control for the closed set. The real Gitea 1.25.4 value for an edit is `changed`,
NOT `modified` a closed allow-list built from the wrong vocabulary would reject every real
docs-only PR, which is a far worse failure than the hole it closes."""
hook.set_pages(
[
{"filename": "docs/a.md", "status": "changed"},
{"filename": "docs/b.md", "status": "added"},
{"filename": "docs/c.md", "status": "deleted"},
],
[],
)
hook.set_pages([{"filename": "docs/a.md", "status": "changed"},
{"filename": "docs/b.md", "status": "added"},
{"filename": "docs/c.md", "status": "deleted"}], [])
assert hook.exempted() is True
@@ -494,7 +483,6 @@ def test_gitea_real_status_values_are_accepted(hook):
# The round-3 `..` finding was an anchor subversion, and mutating the anchors showed no test
# covered them: dropping `^` from the docs/ alternative, or `$` from `.md`, both survived.
def test_docs_must_be_a_PREFIX_not_a_substring(hook):
"""Dropping `^` would exempt `ErsatzTV/docs/Program.cs`."""
hook.set_pages([{"filename": "ErsatzTV/docs/Program.cs", "status": "changed"}], [])
@@ -529,6 +517,7 @@ def test_object_valued_status_is_also_rejected(hook):
assert hook.exempted() is False
# --- The `grep -q` / pipefail inversion, on the ADVISORY side (ersatztv#698) --------------------
#
# Round-2 cross-family review noted the enforced gate gained large-input regression tests while the
@@ -541,7 +530,6 @@ def test_object_valued_status_is_also_rejected(hook):
# negated docs-only test. ~171KB is needed to cross the threshold; every other test in this file uses a
# handful of short paths, which is exactly why the class was invisible here.
def _many_docs(n=1900):
return [f"docs/{'d' * 40}-{i:040d}.md" for i in range(n)]
@@ -551,12 +539,12 @@ def test_a_LARGE_pr_containing_a_code_file_is_NOT_exempt(hook):
bulk of ~171KB still to write."""
hook.set_pages(_rows(["A.cs", *_many_docs()]))
assert hook.exempted() is False, (
"a large PR containing A.cs was granted the docs-only exemption — the predicate inverted"
)
"a large PR containing A.cs was granted the docs-only exemption — the predicate inverted")
def test_positive_control_a_LARGE_genuinely_docs_only_pr_IS_still_exempt(hook):
"""Guards the opposite failure: if large lists merely errored, the test above would pass while the
hook prompted on every big docs PR. Without this, 'fixed' and 'broken' are indistinguishable."""
hook.set_pages(_rows(_many_docs()))
assert hook.exempted() is True, "a large but genuinely docs-only PR lost its exemption"
assert hook.exempted() is True, (
"a large but genuinely docs-only PR lost its exemption")
@@ -1,843 +0,0 @@
"""The scheduled-auto-merge path verifies the protection it rests on (ersatztv#778).
`merge_when_checks_succeed` hands the actual merge to Gitea, to be performed later against whatever
head is green at that moment. Everything the hook proves is therefore a SNAPSHOT. What makes that
safe is stated in the hook and in #622: `review-verdict/h10` is a REQUIRED status check on the base
branch, a commit status belongs to exactly one sha, so a commit pushed after scheduling cannot
inherit the verdict and Gitea's own gate refuses the merge.
That guarantee is branch-protection CONFIG. It lives outside this repo, and before #778 nothing
compared the two the hook asserted it in a comment and in the reason string a human reads, which
is a claim about the past, not a check. These tests pin the conversion of that assumption into a
precondition.
The outcome set is the contract, and each arm is asserted separately because collapsing any two of
them is how this class of guard has failed here before:
* required check PRESENT -> proceed (no opinion drawn from this check)
* branch protection UNREADABLE -> ask (a transient failure is not evidence of safety)
* required check ABSENT -> deny (this is #622's hole reopened, not a degraded read)
* a GLOB rule COULD govern the base -> ask, and distinctly from the unreadable case. This hook
does not reimplement Gitea's glob dialect, so "some rule might apply and we cannot tell" is a
fourth answer, not a flavour of the third. Tests that assert only `"ask" in reason` cannot tell
the two apart and a crashed classifier also produces an ask so each arm is pinned on the
text unique to it.
Observable contract: the hook exits 0 with EMPTY stdout when it has no opinion, and emits a JSON
`permissionDecision` otherwise.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
HOOK = REPO_ROOT / ".claude" / "hooks" / "pretooluse-merge-consent.sh"
SHA = "a9e3e23abf337980ca4c05854f5b1e210099d08b"
SHORT = SHA[:7]
# The PR is deliberately NOT docs-only: the docs-only exemption short-circuits the whole gate before
# the scheduled-merge branch is reached, so a docs PR would pass these tests without ever running
# the code under test.
CURL_SHIM = r"""#!/usr/bin/env python3
import json, os, sys, pathlib, urllib.parse
state = pathlib.Path(os.environ["STUB_DIR"])
args = sys.argv[1:]
url = [a for a in args if a.startswith("http")][-1]
# The branch-protection call uses `-o <file> -w '%{http_code}'` rather than `curl -sf`, precisely so
# it can tell a 200 from every other outcome (`curl -sf` collapses every HTTP error into exit 22
# with empty output, hiding the difference between an empty list and a failed request). It does
# NOT treat 404 as a finding. The shim must therefore behave like real curl for those
# flags: body to the -o file, status code to stdout. A shim that ignored them would make the hook
# read an empty body and a blank code on EVERY path, and the tests would pass by accident against a
# guard that never ran — the "test double's fidelity claim" failure this repo has on record.
def respond(body, code="200"):
if "-o" in args:
pathlib.Path(args[args.index("-o") + 1]).write_text(body)
else:
sys.stdout.write(body)
if "-w" in args:
sys.stdout.write(code)
sys.exit(0)
# RECORD BEFORE FILTERING. This recorder used to live inside the `endswith` branch below, which
# made the "no ref reaches the URL" assertion unfalsifiable: the only URLs it could record were ones
# that already satisfied it, so a by-name request was invisible to the very test written to forbid
# it. Cold review reintroduced a by-name lookup in the hook and the suite stayed 33/33 green. That is
# the filter-on-the-asserted-property defect this PR's sibling record is about, committed inside the
# guard against it — so the recorder now sees EVERY branch-protection URL, whatever its shape.
if "/branch_protections" in url:
with (state / "bp_urls").open("a") as fh:
fh.write(url + "\n")
if url.rstrip("/").endswith("/branch_protections"):
# The hook reads ONLY this endpoint now — the by-name lookup was deleted because it performs no
# matching and knows nothing about rule precedence, so a 200 from it proved less than it looked.
mode = (state / "bp").read_text().strip()
if mode == "TRANSPORT-ERROR":
respond("", "000")
if mode == "FORBIDDEN":
respond('{"message":"token does not have at least one of required scope(s)"}', "403")
if mode == "GARBAGE":
respond('{"message":"not an array"}')
if mode == "EMPTY":
respond('')
if mode == "UNPARSEABLE-RULES":
# A 200 whose rule NAME is a number: `//` fires only on null/false, so the classifier's
# `explode`/`match` throws and the program dies on a read that plainly succeeded.
respond(json.dumps([{"branch_name": 7, "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "LIST-404":
# An HTTP 404 from the LIST endpoint: the repo is absent, or invisible to this credential.
# Gitea answers 404 for both, and it says NOTHING about whether the base is protected.
respond('{"message":"Not Found"}', "404")
if mode == "EMPTY-LIST":
# The list WAS read and holds no rule — the only shape that establishes absence.
respond("[]")
if mode == "LIST-UNREADABLE":
respond('{"message":"internal error"}', "500")
if mode == "GLOB-RULE":
respond(json.dumps([{"branch_name": "m*", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "REGEX-META-RULE":
respond(json.dumps([{"branch_name": "mai.", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "GLOB-WITH-DOT-RULE":
respond(json.dumps([{"branch_name": "release/26.*", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "CHARCLASS-RULE":
respond(json.dumps([{"branch_name": "a[b", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]},
{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "ESCAPED-META-RULE":
respond(json.dumps([{"branch_name": "a\\{b", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "CASEFOLD-RULE":
respond(json.dumps([{"branch_name": "MAIN", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "TWO-CASE-VARIANT-RULES":
respond(json.dumps([{"branch_name": "MAIN", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]},
{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": ["Build ErsatzTV Image / Build & test (.NET)"]}]))
if mode == "NONASCII-RULE":
respond(json.dumps([{"branch_name": "\u00fcnstable", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "EXACT-PLUS-GLOB-RULE":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]},
{"branch_name": "m*", "enable_status_check": True,
"status_check_contexts": ["Build ErsatzTV Image / Build & test (.NET)"]}]))
if mode == "MALFORMED-MEMBER":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": 7}]))
if mode == "SUBSTRING-STRING":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": "prefix-review-verdict/h10-suffix"}]))
if mode == "FALSE-CONTEXTS":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": False}]))
if mode == "STRING-ENABLE":
respond(json.dumps([{"branch_name": "main", "enable_status_check": "true",
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "NON-STRING-MEMBER":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": [7, "review-verdict/h10"]}]))
if mode == "EMPTY-CONTEXTS":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": []}]))
if mode == "NULL-CONTEXTS":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": None}]))
if mode == "LONGER-STRING-MEMBER":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts": ["xx-review-verdict/h10-yy"]}]))
if mode == "STATUS-CHECK-OFF":
respond(json.dumps([{"branch_name": "main", "enable_status_check": False,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "MISSING-CONTEXT":
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts":
["Build ErsatzTV Image / Build & test (.NET) (pull_request)"]}]))
# GUARDED
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts":
["Build ErsatzTV Image / Build & test (.NET) (pull_request)",
"review-verdict/h10"]}]))
if "/pulls/" in url and "/files" in url:
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
page = int(q.get("page", ["1"])[0])
if page == 1:
print(json.dumps([{"filename": "ErsatzTV/Program.cs", "status": "modified"}]))
else:
print("[]")
sys.exit(0)
if "/status" in url and (state / "no_recorded_base").exists():
# A verdict posted before ersatztv#632 carries no `(base: …)` marker, so the #632 detection
# takes its graceful-adoption path and forms no opinion. That isolates the hoisted retarget
# check as the ONLY guard that can deny.
print(json.dumps({"state": "success", "statuses": [
{"context": "review-verdict/h10", "status": "success",
"description": "Review-verdict: MERGEABLE @ %s" % os.environ["STUB_SHORT"]}]}))
sys.exit(0)
if "/status" in url:
# A 2xx body whose `.statuses` IS an array but whose members are scalars. `.statuses | type ==
# "array"` passes; indexing a number then makes jq exit 5 and, under `set -e`, kills the hook
# with no JSON at all.
_m = (state / "bp").read_text().strip()
if _m == "SCALAR-STATUS-ROW":
print('{"state":"success","statuses":[1]}')
sys.exit(0)
if _m == "NONSTRING-STATUS-ROW":
# Object shape, string context, valid description — passes the #632 block, which does NOT
# validate `.status` — but a NUMERIC status. This is the shape that actually reaches the
# scheduled branch's validator, i.e. the new clause's reachable contribution.
print(json.dumps({"state": "success", "statuses": [
{"context": "review-verdict/h10", "status": 7,
"description": "Review-verdict: MERGEABLE @ %s (base: main)"
% os.environ["STUB_SHORT"]}]}))
sys.exit(0)
print(json.dumps({"state": "success", "statuses": [
{"context": "review-verdict/h10", "status": "success",
"description": "Review-verdict: MERGEABLE @ %s (base: main)" % os.environ["STUB_SHORT"]}]}))
sys.exit(0)
if "/issues/" in url and "/comments" in url:
print(json.dumps([{"body": "Review-verdict: MERGEABLE @ %s" % os.environ["STUB_SHORT"]}]))
sys.exit(0)
if "/issues/" in url:
print(json.dumps({"body": "## Done-when\n- [x] everything\n"}))
sys.exit(0)
if "/pulls/" in url:
# The base is served per-GET so a PERSISTENT retarget mid-run can be modelled: the first read
# (top of the hook) sees `main`, a later one sees whatever `retarget` names.
counter = state / "pr_get_count"
n = int(counter.read_text()) if counter.exists() else 0
counter.write_text(str(n + 1))
base = "main"
bo = state / "base_override"
if bo.exists():
base = bo.read_text().strip()
rt = state / "retarget"
if rt.exists() and n >= 1:
base = rt.read_text().strip()
print(json.dumps({"head": {"sha": os.environ["STUB_SHA"]},
"base": {"ref": base, "sha": "b" * 40},
"body": "fixes #1"}))
sys.exit(0)
print("{}")
"""
@pytest.fixture
def hook(tmp_path):
bindir = tmp_path / "bin"
bindir.mkdir()
curl = bindir / "curl"
curl.write_text(CURL_SHIM)
curl.chmod(0o755)
state = tmp_path / "state"
state.mkdir()
(state / "bp").write_text("GUARDED")
env = dict(os.environ)
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["STUB_SHA"] = SHA
env["STUB_SHORT"] = SHORT
env["ETV_GITEA_TOKEN"] = "stub" # noqa: S105 - deliberately fake; the real credential comes from the environment
env["ETV_GITEA_URL"] = "http://gitea.example"
env["CLAUDE_PROJECT_DIR"] = str(REPO_ROOT)
env.pop("ETV_GITEA_BASICAUTH", None)
class Handle:
def set_branch_protection(self, mode):
(state / "bp").write_text(mode)
def set_base(self, ref):
"""The base reported by EVERY PR read — a stable target, not a retarget."""
(state / "base_override").write_text(ref)
def drop_recorded_base(self):
"""Serve a pre-#632 verdict (no `(base: …)`), so only the hoisted check can deny."""
(state / "no_recorded_base").write_text("1")
def set_retarget(self, ref):
"""Persistent retarget: every PR read after the first reports `ref`."""
(state / "retarget").write_text(ref)
def branch_protection_urls(self):
f = state / "bp_urls"
return f.read_text().splitlines() if f.exists() else []
def decision(self, scheduled=True):
payload = {
"tool_input": {
"method": "merge",
"owner": "timothy",
"repo": "ersatztv",
"pull_number": 42,
"merge_when_checks_succeed": scheduled,
}
}
r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), env=env, capture_output=True, text=True)
assert r.returncode == 0, r.stderr
if not r.stdout.strip():
return None
return json.loads(r.stdout)
def reason(self, scheduled=True):
d = self.decision(scheduled=scheduled)
return "" if d is None else json.dumps(d)
return Handle()
def test_a_base_without_the_required_check_denies_a_SCHEDULED_merge(hook):
"""The defect #778 closes: arming an auto-merge while the per-sha gate that makes it safe is
absent. Nothing else in the flow notices, which is what makes it worth a guard."""
hook.set_branch_protection("MISSING-CONTEXT")
reason = hook.reason()
assert "deny" in reason, (
"a scheduled auto-merge was armed with no 'review-verdict/h10' required check on the base — "
"that is ersatztv#622's hole reopened"
)
assert "review-verdict/h10" in reason, (
"the deny must name the missing context; a reader cannot act on 'branch protection is wrong'"
)
def test_status_checks_disabled_wholesale_also_denies(hook):
"""The context can be listed while `enable_status_check` is false, in which case Gitea enforces
none of them. Reading only the list would report the protection as present the same
check-the-label-not-the-capability shape (#697/#698) this repo has paid for twice."""
hook.set_branch_protection("STATUS-CHECK-OFF")
reason = hook.reason()
assert "deny" in reason, "status checks were disabled entirely and the listed context was read as protection anyway"
def test_positive_control_a_guarded_base_REACHES_the_check_and_still_auto_grants(hook):
"""Without this, every test above passes if the hook denies on all paths — which it very nearly
does, since this PR is non-docs and several later conditions are stubbed only loosely.
Asserting the absence of one phrase was not enough (cold review): an unrelated early `ask`, or a
differently-worded deny, would satisfy it while proving nothing. So this pins all three of the
things that must be true the branch-protection endpoint was actually CALLED, the decision is
`allow`, and the reason is the satisfied-gate message rather than any refusal.
"""
decision = hook.decision()
assert hook.branch_protection_urls(), (
"the guarded case never reached the branch-protection endpoint, so the other tests are not "
"exercising the code they claim to"
)
assert decision is not None, "the hook passed through instead of auto-granting"
verdict = decision["hookSpecificOutput"]["permissionDecision"]
assert verdict == "allow", f"a fully-satisfied gate did not auto-grant (got {verdict!r})"
assert "satisfied" in decision["hookSpecificOutput"]["permissionDecisionReason"]
@pytest.mark.parametrize("shape", ["SUBSTRING-STRING", "LONGER-STRING-MEMBER"])
def test_a_context_name_that_merely_CONTAINS_the_required_one_does_not_satisfy_it(hook, shape):
"""The false-OPEN this guard must not have.
jq's `index()` on a STRING is substring search, so `"prefix-review-verdict/h10-suffix"` answers
yes to a naive membership test auto-granting a scheduled merge on a base where the context is
not required at all. `LONGER-STRING-MEMBER` covers the same confusion inside a real array.
A false-closed here costs one prompt; a false-open costs an unreviewed merge, so the membership
test is exact equality over a value first proven to be an array of strings.
"""
hook.set_branch_protection(shape)
reason = hook.reason()
assert "allow" not in reason or "deny" in reason or "ask" in reason, (
f"payload shape {shape} auto-granted a scheduled merge"
)
assert "satisfied" not in reason, (
f"a context name that merely contains 'review-verdict/h10' ({shape}) was accepted as it"
)
@pytest.mark.parametrize("shape", ["EMPTY-CONTEXTS", "NULL-CONTEXTS"])
def test_an_empty_or_null_contexts_list_DENIES_rather_than_asking(hook, shape):
"""Absent is the finding, not a read failure. An empty or null list is a well-formed answer
meaning "nothing is required here", so it must take the deny arm and not be swept into the
unknown-shape ask alongside genuinely unreadable payloads."""
hook.set_branch_protection(shape)
reason = hook.reason()
assert "deny" in reason, f"{shape} was treated as unreadable rather than as a confirmed absent required check"
def test_a_FALSE_contexts_value_asks_rather_than_denying(hook):
"""jq's `//` alternative fires on `false`, not only on null, so `// []` mapped this malformed
payload to an empty list and answered "no" a confident deny derived from a shape that was
never understood. Absent and null are defaulted explicitly; everything else is unknown."""
hook.set_branch_protection("FALSE-CONTEXTS")
reason = hook.reason()
assert "ask" in reason, "a false contexts value was defaulted to [] and produced a deny"
def test_a_non_string_MEMBER_inside_the_array_asks(hook):
"""`[7, "review-verdict/h10"]` contains the context, but the payload is not the shape this
guard knows how to reason about. Answering "yes" would mean trusting a structure we cannot
validate; the honest answer is that we could not tell."""
hook.set_branch_protection("NON-STRING-MEMBER")
reason = hook.reason()
assert "ask" in reason, "an array with a non-string member produced a decision anyway"
def test_a_malformed_contexts_MEMBER_asks_rather_than_denying_with_the_wrong_reason(hook):
"""One level below the response-shape check, and it survives it.
`{"status_check_contexts": 7}` is a perfectly good object, so the top-level type guard passes;
jq then errors on the member, `|| true` turns that into an empty string, and a two-way test
would report "NOT a required status check" a confident, specific, wrong diagnosis of a payload
that was never read. The same swallow one level down as the #632 base-change guard's second fix.
"""
hook.set_branch_protection("MALFORMED-MEMBER")
reason = hook.reason()
assert "ask" in reason, "a malformed contexts member produced a decision instead of a question"
assert "NOT a required status check" not in reason, (
"an unreadable payload was reported as a confirmed missing required check"
)
def test_a_base_with_NO_branch_protection_at_all_denies_rather_than_asking(hook):
"""The strongest form of the thing being checked, and the likeliest real trigger.
A rule list that is READABLE and EMPTY has nothing that can govern any base, so
`review-verdict/h10` is definitively not required and scheduling an auto-merge is #622's hole.
That must DENY, not ask: routing the most likely real-world trigger branch protection removed
to a human prompt would make it read like a transient hiccup.
Absence is established by the LIST, never by a status code; this fixture returns 200 with `[]`.
An HTTP 404 means the repo was absent or invisible to the credential and is a read failure,
covered by `test_an_HTTP_404_on_the_LIST_read_asks_and_does_not_claim_the_list_was_read`.
"""
hook.set_branch_protection("EMPTY-LIST")
reason = hook.reason()
assert "deny" in reason, "a base with no branch protection at all did not deny a scheduled auto-merge"
assert "none matches" in reason, (
"the deny must distinguish 'the list was read and nothing governs this base' from 'could not read'"
)
def test_a_403_asks_because_it_says_only_that_we_could_not_look(hook):
"""A credential without the repo-admin scope this endpoint needs proves nothing about the
protection, so it must NOT deny otherwise the guard strands every scheduled merge run made
with a narrower token."""
hook.set_branch_protection("FORBIDDEN")
reason = hook.reason()
assert "ask" in reason, "a 403 was treated as evidence about the protection"
assert "none matches" not in reason, "a 403 was reported as a confirmed absence of branch protection"
@pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE", "EMPTY", "LIST-404"])
def test_an_UNREADABLE_branch_protection_asks_rather_than_denying_or_passing(hook, failure):
""" "Could not check" is a third outcome, not a synonym for either neighbour.
Denying would strand every scheduled merge on a Gitea hiccup or on credentials without the
repo-admin scope this endpoint needs. Passing would be worse: it would restore the exact
unverified assumption #778 exists to remove, while now printing a reason string claiming the
protection was confirmed.
"""
hook.set_branch_protection(failure)
reason = hook.reason()
assert "ask" in reason, f"an unreadable branch-protection response ({failure}) did not fall through to a human"
assert "branch-protection rules" in reason, "the ask must name what could not be checked"
def test_an_IMMEDIATE_merge_is_not_subjected_to_this_check(hook):
"""Scope, deliberately narrow — and stated without the overclaim cold review removed.
An immediate merge is not window-FREE: the hook returns `allow` and a separate call performs the
merge, so a push can still land in between. What it lacks is a SCHEDULER nothing waits on
pending checks, so the gap is one tool call rather than however long CI takes. The required
branch-protection check is what protects the scheduled path specifically, so extending this deny
to immediate merges would block a materially safer operation and invite the whole guard being
switched off. The residual on this path is carried in docs/remote-state-inventory.md.
"""
hook.set_branch_protection("MISSING-CONTEXT")
reason = hook.reason(scheduled=False)
assert "NOT a required status check" not in reason, (
"the required-check deny fired on an immediate merge, which has no post-scheduling window"
)
def test_a_NON_BOOLEAN_enable_status_check_asks(hook):
"""`"true"` is not `true`. Comparing the string to `true` yields a confident "no" -> deny from a
payload never understood, which collapses the documented tri-state into two states. Every
malformed shape on this endpoint has to reach the same ask arm."""
hook.set_branch_protection("STRING-ENABLE")
reason = hook.reason()
assert "ask" in reason, "a string enable_status_check produced a decision instead of a question"
assert "NOT a required status check" not in reason
def test_a_SCALAR_status_row_asks_instead_of_killing_the_hook(hook):
"""The consent hook's contract is that it always emits exactly one of grant/deny/ask, and
`{"statuses":[1]}` is the payload that can break it: it passes an `.statuses | type == "array"`
check, after which indexing a number errors and exits 5, which under `set -e` aborts the hook
with NO JSON at all. A gate that emits nothing has not failed closed; it has failed to decide.
WHAT THIS TEST DOES *NOT* PROVE, stated because the mutation showed it. Restoring the
scheduled-branch validation to its predecessor leaves this test GREEN, because the #632
base-retarget block runs FIRST and validates the members it consumes so it catches THIS
payload and asks before the scheduled branch is reached. The two guards overlap, which is the
masking shape `duplicate guards mask each other` describes.
The caveat is scoped to this payload, NOT to the clause. The earlier block validates `.context`
and `.description` but not `.status`, so an object row with a numeric status passes it and does
reach the new validator `test_a_NON_STRING_status_reaches_the_scheduled_validator` covers that
and goes red when the clause is removed. So the clause is masked for scalar rows and load-bearing
for that one. An earlier draft called the whole clause defence-in-depth, understating it in the
opposite direction from this repo's usual error.
So this asserts the OBSERVABLE contract a decision is always emitted for this payload which
is true and worth pinning whichever guard supplies it. It is deliberately not offered as a
mutation proof of the newer clause, because it is not one.
"""
hook.set_branch_protection("SCALAR-STATUS-ROW")
decision = hook.decision()
assert decision is not None, (
"the hook emitted no decision at all for a malformed statuses payload — it neither granted, denied nor asked"
)
assert decision["hookSpecificOutput"]["permissionDecision"] == "ask"
def test_a_PERSISTENT_retarget_denies_on_the_IMMEDIATE_path_too(hook):
"""The twin. The re-read first landed inside the scheduled branch only, so this exact case —
same fixture, `merge_when_checks_succeed` absent AUTO-GRANTED while its sibling denied.
Cold review demonstrated it side by side, and it is the shape this repo has on record as
"fix one path, then check its TWIN": the fix was applied where the defect was noticed, and the
other consumer of the same stale value kept it. The re-read is now hoisted above every
base-dependent decision rather than duplicated into the branch that happened to be under review.
"""
hook.drop_recorded_base()
hook.set_retarget("scratch")
reason = hook.reason(scheduled=False)
assert "deny" in reason, "an immediate merge was auto-granted after the PR was retargeted mid-evaluation"
assert "scratch" in reason and "main" in reason
def test_a_PERSISTENT_retarget_after_the_first_read_denies(hook):
"""The defect this guard had itself, found in the fifth cold-review round.
`$base_ref` is captured from the PR snapshot at the top of the hook, and everything between
then and the branch-protection lookup is round trips the file enumeration alone can be forty
pages. A retarget in that gap needs no ABA and no force-push: the lookup would name the OLD
base, confirm `review-verdict/h10` on a branch the PR no longer targets, and grant a scheduled
merge onto one that may require nothing. Checking a stale identifier is not checking, which is
the whole of `process.check-and-use-pins-a-version` so the guard enforcing that rule had to
stop breaking it.
"""
hook.drop_recorded_base()
hook.set_retarget("scratch")
reason = hook.reason()
assert "deny" in reason, "the PR was retargeted mid-evaluation and the gate still granted on the original base"
assert "scratch" in reason and "main" in reason, (
"the deny must name both branches; a reader cannot act on 'the base changed'"
)
def test_a_NON_STRING_status_reaches_the_scheduled_validator(hook):
"""The reachable contribution of the scheduled-branch member validation, which the previous
caveat understated.
The #632 block validates `.context` and `.description` but NOT `.status`, so an object row with
a numeric status passes it and arrives here. Without this clause it becomes `vstate=7` and falls
to the catch-all deny arm fail-closed, but reported as "the verdict is '7'" rather than as a
payload that could not be read. So the clause is masked for scalar rows and load-bearing for
this one; the caveat on the scalar test is scoped accordingly.
"""
hook.set_branch_protection("NONSTRING-STATUS-ROW")
reason = hook.reason()
assert "ask" in reason, "a non-string .status produced a verdict-shaped decision"
def test_a_base_that_a_GLOB_rule_could_govern_ASKS(hook):
"""A base covered only by a glob rule has no rule bearing its own name, and the deleted by-name
endpoint answered 404 for exactly that read as "unprotected", producing a hard DENY with a
specific, false cause.
But the opposite error is worse: deciding the glob DOES match would auto-grant on a base whose
protection was never established. This hook does not reimplement Gitea's glob dialect (its `*`
does not cross `/`, and `?`/`[]`/`{a,b}` are wildcards), so a glob rule that COULD govern the
base is undecidable and asks the only answer honest in both directions.
"""
hook.set_branch_protection("GLOB-RULE")
reason = hook.reason()
assert "ask" in reason, "a base a glob rule could govern was decided rather than referred to a human"
assert "could govern it" in reason, (
"the ask came from the generic could-not-read arm, not the undecidable-glob arm — those "
"are different outcomes and a crashed classifier must not pass as a correct classification"
)
assert "none matches" not in reason, "a base a glob rule could govern was reported as having no protection at all"
def test_an_unreadable_rule_LIST_asks_rather_than_denying(hook):
"""A read failure must not convert 'could not confirm' into 'confirmed absent'. Absence is
established only by the classifier returning `nomatch` over a list that WAS read."""
hook.set_branch_protection("LIST-UNREADABLE")
reason = hook.reason()
assert "ask" in reason, "an unreadable rule list was treated as proof of absence"
assert "none matches" not in reason
def test_the_protection_lookup_puts_NO_ref_in_the_url(hook):
"""The successor to a URL-encoding test, and the reason it could be retired.
The ref used to be interpolated into `branch_protections/{name}`, where a base like
`release/26.4` injected a path separator and 404'd — read as "unprotected". That endpoint is
gone: it performed no matching and knew nothing about rule precedence, so a 200 from it proved
less than it looked. Only the LIST endpoint is read now, which takes no ref at all, so the whole
encoding hazard is removed by construction rather than escaped.
"""
hook.set_base("release/26.4")
hook.drop_recorded_base()
hook.reason()
urls = hook.branch_protection_urls()
assert urls, "the branch-protection endpoint was never requested"
for u in urls:
assert u.rstrip("/").endswith("/branch_protections"), f"a ref reached the branch-protection URL: {u}"
def test_a_rule_name_with_REGEX_METACHARACTERS_does_not_match_a_different_base(hook):
"""The false-open in the glob fallback: `*` must be the only wildcard.
Substituting `*` into a raw regex left every other metacharacter live, so a rule named `mai.`
matched the base `main` (and `a+b` matched `aab`). A spurious match to some OTHER rule that
happens to require `review-verdict/h10` reports this base as protected when nothing governs it
a consent gate answering yes on evidence about a different branch. Verified directly before the
fix: `main.x` matched `mainax`.
Here the only rule is `mai.`, which governs a branch that is not `main`, so nothing protects the
base and the gate must deny rather than grant.
"""
hook.set_branch_protection("REGEX-META-RULE")
reason = hook.reason()
assert "deny" in reason, "a rule named 'mai.' was regex-matched against base 'main' and read as protection"
assert "none matches" in reason, (
"'mai.' contains no GLOB metacharacter, so it is decidable: it simply does not govern "
"'main', and the base is genuinely unprotected"
)
def test_a_GLOB_rule_whose_literal_part_has_a_metacharacter_still_MATCHES(hook):
"""The positive control the first escaping attempt lacked, and the reason it looked green.
Escaping is only half the property: `*` must still span. The first version emitted TWO
backslashes (`\\.` = "a literal backslash, then any character"), which made every rule
containing a metacharacter UNMATCHABLE so the fallback found nothing and hard-denied with the
stated cause that no rule can govern the base a false-open converted into a false DENY.
A negative-only assertion cannot see that: a rule matched literally and a rule made unmatchable
both fail to match the wrong base. Only a rule that SHOULD match distinguishes them. Here the rule
`release/26.*` could govern the base `release/26.4`, so the gate must reach a decision about it
rather than reporting the base as unprotected.
"""
hook.set_base("release/26.4")
hook.drop_recorded_base() # keep the #632 comparison out of this test's way
hook.set_branch_protection("GLOB-WITH-DOT-RULE")
reason = hook.reason()
assert "ask" in reason, (
"the glob rule 'release/26.*' could govern base 'release/26.4', which is undecidable here and must ask"
)
assert "could govern it" in reason, (
"the ask must come from the undecidable-glob arm, not from the classifier failing"
)
assert "none matches" not in reason, (
"a base a glob rule could govern was reported as entirely unprotected — the over-escaping "
"failure this test exists to catch"
)
def test_a_rule_name_containing_a_CHAR_CLASS_bracket_does_not_crash_the_matcher(hook):
"""`a[b` built the pattern `a\\[b`, which is a premature end of char-class: jq exits 5, the
`|| true` swallows it, and the whole list is discarded so an unrelated rule's NAME could
poison the lookup and deny a base that a later rule in the same list protects."""
hook.set_branch_protection("CHARCLASS-RULE")
reason = hook.reason()
assert reason, "the hook emitted no decision at all"
assert "ask" not in reason, (
"the exact-name rule was decidable and must have been honoured; an ask here means the "
"classifier failed rather than classified"
)
assert "none matches" not in reason, (
"one rule with a bracket in its name discarded the whole list, including the exact-name "
"rule that actually protects this base"
)
assert "deny" not in reason, "an exact-name rule requiring review-verdict/h10 was present and was not honoured"
def test_a_plain_rule_name_is_matched_CASE_INSENSITIVELY(hook):
"""Gitea compares a rule name with no glob metacharacter using `EqualFold`, so a rule named
`MAIN` governs the base `main`. Comparing case-sensitively here would find no rule, conclude the
base is unprotected, and deny with a false stated cause."""
hook.set_branch_protection("CASEFOLD-RULE")
reason = hook.reason()
assert "ask" not in reason, (
"the case-folded exact rule was decidable and must have been honoured; an ask means the "
"classifier failed rather than classified"
)
assert "none matches" not in reason, (
"a rule named 'MAIN' governs base 'main' in Gitea but was missed by a case-sensitive compare"
)
assert "deny" not in reason
def test_a_BACKSLASH_ESCAPED_metacharacter_in_a_rule_name_is_undecidable_not_absent(hook):
"""The one case that breaks the superset proof the `none` arm rests on.
`none` authorises a DENY on the stated grounds that nothing can possibly govern this base, so
its premise must hold unconditionally. gobwas/glob reads `\\{` as a LITERAL brace, so the rule
`a\\{b` governs the base `a{b`; a superset that treated `\\` as an ordinary character would build
`a\\.*b`, fail to match, and deny a base that is in fact protected. Treating backslash as a
metacharacter restores the property.
Git ref rules make this nearly unreachable a branch name may not contain `*`, `?`, `[` or `\\`
but `{` IS legal in a branch name, and "nearly unreachable" is not the standard for the arm
that issues a deny.
"""
hook.set_base("a{b")
hook.drop_recorded_base()
hook.set_branch_protection("ESCAPED-META-RULE")
reason = hook.reason()
assert "none matches" not in reason, (
"a rule whose escaped brace governs this base was reported as unable to govern it"
)
assert "ask" in reason, "an escaped-metacharacter rule is undecidable here and must ask"
assert "could govern it" in reason, (
"the ask must come from the undecidable-glob arm, not from the classifier failing — this "
"test hits the same arm as its two siblings and needs the same pin"
)
def test_the_precedence_check_runs_even_when_an_exactly_named_rule_EXISTS(hook):
"""The twin the restructure deletes, pinned so it cannot come back.
The hook used to look the rule up by NAME first and only enumerate the list on a 404. That
by-name endpoint is an exact DB lookup that performs no matching and knows nothing about
precedence, so on a 200 the path this repo actually takes, since its rule IS named `main`
the gate granted having consulted one rule and never asked which rule Gitea would apply. The
precedence argument guarded the 404 path only: hardened code that was dead, next to live code
that was not.
`EXACT-PLUS-GLOB-RULE` is exactly that configuration: a rule NAMED `main` that requires
`review-verdict/h10`, plus `m*` that does not. Under the old flow the by-name hit returned the
`main` rule, saw h10 and granted. Now there is one path, so the classifier sees both rules and
refuses to guess which one Gitea applies.
"""
hook.set_branch_protection("EXACT-PLUS-GLOB-RULE")
reason = hook.reason()
assert "ask" in reason, "an exactly-named rule was trusted without asking which rule Gitea would actually apply"
assert "could govern it" in reason
# And the by-name endpoint must not be consulted at all — its existence is what split the paths.
for u in hook.branch_protection_urls():
assert u.rstrip("/").endswith("/branch_protections"), (
f"the by-name lookup is back, and with it the unguarded path: {u}"
)
def test_a_GLOB_rule_that_could_outrank_an_exact_one_wins_and_ASKS(hook):
"""The arm ORDER, pinned. Without this the reorder is invisible to the suite — swapping the arms
back left all 29 tests green, which is how an unproven change ships.
Gitea picks the governing rule with `GetFirstMatched` over a list sorted by Priority and THEN by
plain-name-ness, so a glob rule can outrank an exactly-named one. Here `main` requires
`review-verdict/h10` and `m*` does not. Evaluating `exact` first inspects the rule that requires
h10, concludes the base is protected, and AUTO-GRANTS a scheduled merge onto a base where the
check may not be enforced at all #622's hole, reached through the block written to close it.
Evaluating `undecidable` first is sound without knowing Gitea's precedence rules, which is the
only claim this code is entitled to make about somebody else's resolver.
"""
hook.set_branch_protection("EXACT-PLUS-GLOB-RULE")
reason = hook.reason()
assert "ask" in reason, (
"an exact rule was trusted while a glob rule could outrank it — the gate granted on a base "
"whose enforced rule it never identified"
)
assert "could govern it" in reason, (
"the ask must come from the undecidable-glob arm, not from the classifier failing"
)
def test_two_rules_differing_only_in_CASE_are_undecidable(hook):
"""`first` picks list order; Gitea picks by Priority. With `MAIN` requiring `review-verdict/h10`
and `main` not, inspecting whichever the API happened to list first would auto-grant on a base
whose enforced rule was never identified the same defect as the arm order, one level down."""
hook.set_branch_protection("TWO-CASE-VARIANT-RULES")
reason = hook.reason()
assert "ask" in reason, "two fold-equal rules disagree about review-verdict/h10 and one was picked by list order"
assert "could govern it" in reason
def test_a_NON_ASCII_rule_or_base_is_undecidable_rather_than_fold_compared(hook):
"""`ascii_downcase` is not Gitea's Unicode-aware `EqualFold`, so a rule `ünstable` and a base
`Ünstable` fold equal there and not here. The miss lands on `none`, which DENIES with the stated
cause "none matches" and the backslash arm already rejects "nearly unreachable"
as a standard for the arm that issues a deny, so the same standard applies here."""
hook.set_base("\u00dcnstable")
hook.drop_recorded_base()
hook.set_branch_protection("NONASCII-RULE")
reason = hook.reason()
assert "none matches" not in reason, (
"a rule that folds equal to this base under EqualFold was reported as unable to govern it"
)
assert "ask" in reason
def test_an_HTTP_404_on_the_LIST_read_asks_and_does_not_claim_the_list_was_read(hook):
"""The `nomatch` sentinel, pinned — it shipped UNPINNED, and a full revert left the suite green.
Absence must be established by the CLASSIFIER over a list that was actually read, never by an
HTTP status. Gitea answers 404 on this endpoint when the repo is absent or invisible to the
credential, which says nothing about the base. Reusing 404 for the classifier's own
nothing-can-govern verdict let that read reach the deny whose reason states "the full rule list
was read and none matches" — a claim about a read that never happened.
No fixture emitted an HTTP 404 on the list before this test, which is exactly why reverting the
sentinel to `bp_code=404` changed nothing observable.
"""
hook.set_branch_protection("LIST-404")
reason = hook.reason()
assert "ask" in reason, "an unreadable repo was treated as evidence about the base"
assert "none matches" not in reason, "a 404 read claimed the full rule list had been read and matched nothing"
@pytest.mark.parametrize("shape", ["UNPARSEABLE-RULES", "GARBAGE", "EMPTY"])
def test_a_200_the_classifier_cannot_PARSE_asks_without_blaming_the_transport(hook, shape):
"""The twin of the `nomatch` fix, on the other arm — and pinned this time rather than assumed.
A rule whose `branch_name` is a number makes the classifier throw on a read that plainly
succeeded. Mapping that to `bp_code=000` produced "could not read … (HTTP '000' — Gitea
unreachable)", stating a transport cause for a 200. The decision (ask) was always safe; only
the reason lied, which is precisely the defect corrected one arm over for the deny.
Parametrised over all three shapes that reach a 200 the hook cannot use, because the first fix
covered only `UNPARSEABLE-RULES` the arm where it was noticed. `GARBAGE` (an object, not an
array) and `EMPTY` are diverted one branch EARLIER, by the array gate, and kept `bp_code=200`,
so they reported "HTTP '200' — Gitea unreachable" about a successful read. Fixing one arm and
leaving its twin is the shape this PR is largely about.
"""
hook.set_branch_protection(shape)
reason = hook.reason()
assert "ask" in reason, "an unparseable rule list produced a decision instead of a question"
assert "could not parse" in reason, "the ask blamed the transport for a 200 the classifier simply could not read"
assert "unreachable" not in reason
-464
View File
@@ -1,464 +0,0 @@
"""Every `MUTATION` row of `docs/guard-inventory.md` is EXECUTED, not asserted (ersatztv#790).
The `MUTATION` grade means "a clause-level mutation was executed and this named test was witnessed
red". Witnessed once, by hand, that is evidence about the day the row was written and nothing else:
it decays as soon as the guard is edited, and a wrong grade has no way to announce itself.
So this file re-runs every one of them: for each declared mutation in `mutation_manifest.py`, apply
it to an isolated copy of this repository and require the row's OWN named test to go red.
WHAT A GREEN RUN HERE DOES AND DOES NOT PROVE stated because a mutation harness that overclaims is
the same defect one level up:
* It proves the recorded proof ref names a test that EXISTS, still collects, and still reacts to
the declared clause. That is precisely the decay #790 was filed about.
* It proves the declared clause still occurs, exactly once, in the entry's declared `target` —
which is not always the guard's own file. A clause that has been reworded fails here rather than
silently mutating nothing.
* Combined with the positive control below, it proves every named proof test is GREEN on the
unmutated sandbox so "the mutation was noticed" cannot be confused with "the test was already
red". For the proof tests that perform a disarm of their own, that control also runs the disarm;
the two named tests that are plain production set-equality checks have no disarm of their own,
and this harness supplies theirs.
* It does NOT prove the declared clause is the ONLY thing the guard hangs on. Some entries redden
through the proof test's own "the clause has moved, RETARGET this" assertion rather than through
changed behaviour. That is the intended reading, not a hole: those tests perform their
behavioural disarm themselves on every green run, and what they could not do is notice their own
clause reference going stale. Which entries those are is a dated measurement and lives in
`docs/decisions/records/testing/mutation-claims-are-executed.md`, not here.
The `granularity` column is where this file refuses to flatter itself. See `Mutation` in
`mutation_harness_lib.py`: all but one guard admits a single-clause mutation, and the one that does
not CARRIES the finer mutation that survived, which is re-run and required to keep surviving.
"""
from __future__ import annotations
import importlib.util
import re
import shutil
import sys
from dataclasses import replace
from pathlib import Path
import pytest
from scripts.tests.mutation_harness_lib import (
_BASELINES,
Mutation,
_git,
build_sandbox,
reset_sandbox,
run_pytest,
verify_mutation,
)
from scripts.tests.mutation_manifest import MUTATIONS, UNDECLARED
REPO_ROOT = Path(__file__).resolve().parents[2]
INVENTORY = REPO_ROOT / "docs" / "guard-inventory.md"
# `| `guard` | blocks | kind | proof | proof ref |` — the five-column inventory row. The column
# count is what excludes the two-column tables elsewhere in the file; `test_the_inventory_rows_parse`
# below is the floor that catches the shape changing under it.
_ROW = re.compile(r"^\|\s*`([^`]+)`\s*\|([^|]*)\|\s*(\w[\w-]*)\s*\|\s*([A-Z-]+)\s*\|\s*(.*?)\s*\|\s*$", re.M)
def _inventory_rows() -> list[tuple[str, str, str, str]]:
"""(guard, kind, proof grade, proof ref) for every inventory row.
Derived from the document the grades live in, never hand-listed: the whole point is that a row
cannot change its grade without this file noticing.
"""
rows = [(m.group(1), m.group(3), m.group(4), m.group(5).strip("`")) for m in _ROW.finditer(INVENTORY.read_text())]
guards = [g for g, _k, _p, _r in rows]
duplicates = sorted({g for g in guards if guards.count(g) > 1})
assert not duplicates, (
f"the inventory lists {duplicates} more than once. Every comparison below reduces rows "
"through a set or a dict, so a duplicate row is INVISIBLE to this file — it would report all "
"entries verified over a table that contradicts itself. `test_guard_inventory.py` catches "
"this too, but only when the whole suite runs, and this file is routinely run alone."
)
assert len(rows) >= 50, (
f"only parsed {len(rows)} inventory rows — the table's shape has changed and this regex now "
"reads a fraction of it. Every set comparison below would then pass over a population that "
"is mostly missing, which is the vacuous-completeness failure the inventory itself exists to "
"prevent."
)
return rows
# ------------------------------------------------------------------------------------------------
# THE MANIFEST IS PINNED TO THE INVENTORY — a grade cannot change without this file changing
# ------------------------------------------------------------------------------------------------
def test_the_manifest_covers_exactly_the_MUTATION_rows():
"""Set equality, both directions, because they are different defects.
A `MUTATION` row with no manifest entry is a claim nothing checks the state #790 was filed
about. A manifest entry for a row that is no longer graded `MUTATION` is a check whose subject
has moved out from under it, and it would keep passing.
"""
graded = {guard for guard, _kind, grade, _ref in _inventory_rows() if grade == "MUTATION"}
declared = {m.guard for m in MUTATIONS}
assert graded, "no row in the inventory is graded MUTATION, so this whole file would prove nothing"
assert declared - graded == set(), (
f"declared mutations for rows that are not graded MUTATION: {sorted(declared - graded)}"
)
assert graded - declared == set(), (
f"these rows claim a MUTATION proof with nothing executing it: {sorted(graded - declared)}. "
"Declare the clause in mutation_manifest.py, or regrade the row."
)
assert len(MUTATIONS) == len(declared), "two manifest entries name the same guard"
def test_every_declared_mutation_names_the_row_s_OWN_proof_ref():
"""The manifest may not point at a different test than the row does.
Without this, the inventory could keep citing a stale proof while the harness quietly exercised
a healthier one, and the row would read as verified.
"""
refs = {guard: ref for guard, _kind, grade, ref in _inventory_rows() if grade == "MUTATION"}
wrong = [(m.guard, m.proof, refs.get(m.guard)) for m in MUTATIONS if refs.get(m.guard) != m.proof]
assert not wrong, f"manifest proof ref disagrees with the inventory row: {wrong}"
def test_every_GUARD_row_is_either_DECLARED_or_STATED_here():
"""`Done-when`: guards whose mutation cannot be declared are STATED, not silently skipped.
Set equality against the inventory's GUARD rows, both directions, keyed on the guard. Keying on
the row's GRADE instead would be cheaper and tautological — a new guard graded NONE would inherit
a reason automatically and nobody would look at it and a COUNT moves only on net change, so one
guard arriving as another is promoted leaves it unchanged. This is the same hand-maintained,
machine-checked shape as `docs/guard-inventory.md` itself, which is what makes it safe.
The partition covers `Kind == GUARD` rows only. `TOOLING` asserts nothing and `PROOF` files exist
to prove other guards, so neither carries a mutation claim to verify a rule about the Kind
column rather than a list anyone maintains.
"""
guards = {guard for guard, kind, _grade, _ref in _inventory_rows() if kind == "GUARD"}
declared = {m.guard for m in MUTATIONS}
stated = set(UNDECLARED)
assert not (declared & stated), (
f"these guards are both declared and stated as undeclared: {sorted(declared & stated)}"
)
unaccounted = sorted(guards - declared - stated)
assert not unaccounted, (
f"these guards are neither declared nor stated: {unaccounted}. Declare the clause in "
"mutation_manifest.py, or write a line in UNDECLARED saying what a proof would need — "
"silence is the one option that is not available."
)
orphaned = sorted(stated - guards)
assert not orphaned, (
f"UNDECLARED names rows that are not GUARD-kind rows any more: {orphaned}. A reason for a "
"guard that no longer exists reads as coverage."
)
thin = [g for g, reason in UNDECLARED.items() if len(reason.strip()) < 60]
assert not thin, f"these UNDECLARED entries say nothing a reader could act on: {thin}"
def test_every_entry_declares_a_known_granularity_and_DETECTOR_entries_CARRY_their_survivor():
"""`DETECTOR` is an admission, and an unevidenced one would be a grading curve.
#790's complaint about `pin_population_faults` — that neutering a whole helper is "coarse enough
that a single surviving clause would not be noticed" — applies to every entry graded here. So an
entry may only claim `DETECTOR` while carrying the finer mutation that was tried, as data rather
than as a sentence: `test_every_SURVIVING_clause_mutation_still_does` then runs it.
"""
bad = [m.guard for m in MUTATIONS if m.granularity not in (Mutation.CLAUSE, Mutation.DETECTOR)]
assert not bad, f"unknown granularity on {bad}; allowed: CLAUSE, DETECTOR"
assert all(m.why.strip() for m in MUTATIONS), "every declared mutation must say what its clause does"
unevidenced = [m.guard for m in MUTATIONS if m.granularity == Mutation.DETECTOR and not m.survived_clause]
assert not unevidenced, (
f"these entries claim DETECTOR granularity while naming no finer mutation that was tried: "
f"{unevidenced}. Carry the survivor, or declare the finer clause instead."
)
misplaced = [m.guard for m in MUTATIONS if m.granularity == Mutation.CLAUSE and m.survived_clause]
assert not misplaced, (
f"these entries are graded CLAUSE but carry a surviving finer mutation: {misplaced}. If a "
"finer clause exists and survives, the grade is DETECTOR."
)
half = [m.guard for m in MUTATIONS if bool(m.survived_clause) != bool(m.survived_replacement)]
assert not half, f"a survivor needs both a clause and a replacement: {half}"
def test_every_entry_declares_a_SPECIFIC_diagnostic_it_must_redden_with():
"""`expect` is what stops exit code 1 from being the whole verdict, so it cannot be a token.
An empty or near-empty expectation matches any output and hands the verdict straight back to the
exit status the state this field exists to leave. It must also be a substring of no other
entry's, or two rows could be satisfied by one another's diagnostic.
"""
vague = [(m.guard, m.expect) for m in MUTATIONS if len(m.expect.strip()) < 20]
assert not vague, f"these expectations are too weak to distinguish one red from another: {vague}"
for m in MUTATIONS:
clashes = [o.guard for o in MUTATIONS if o is not m and m.expect in o.expect]
assert not clashes, f"{m.guard}'s expectation is contained in {clashes}'s — neither is specific"
# ------------------------------------------------------------------------------------------------
# THE EXECUTION — one isolated repository, reused, reset between mutations
# ------------------------------------------------------------------------------------------------
@pytest.fixture(scope="session")
def sandbox(tmp_path_factory):
"""An isolated copy of this repository, with the POSITIVE CONTROL already run in it.
The control is inside the fixture rather than in a test of its own so ordering is a dependency
rather than a convention: no mutation can be judged before every proof test has been shown green
on the unmutated tree. Without it, "the named test went red" is satisfied just as well by a proof
test that was already broken, and reporting that as a verified mutation is the failure mode this
whole file exists to remove.
"""
sb = build_sandbox(tmp_path_factory.mktemp("mutation-sandbox"))
# The sandbox holds what GIT TRACKS, so a proof test in a file that has never been `git add`ed is
# simply absent there and the run below reports "file or directory not found" — accurate, and
# unreadable as a diagnosis. Named here instead, because this is what a developer adding a guard
# hits first.
missing = sorted({m.node_id.split("::")[0] for m in MUTATIONS if not (sb / m.node_id.split("::")[0]).is_file()})
assert not missing, (
f"these proof files are not in the sandbox: {missing}. The sandbox is derived from "
"`git ls-files`, so an unstaged new file is not in it — `git add` it and re-run."
)
result = run_pytest(sb, [m.node_id for m in MUTATIONS])
assert result.returncode == 0, (
"the proof tests named by the inventory are NOT green on an unmutated copy of this "
"repository, so nothing below can distinguish 'the mutation was noticed' from 'the test was "
f"already red'. Fix them first.\n{result.stdout[-4000:]}{result.stderr[-2000:]}"
)
assert "passed" in result.stdout, f"the control run collected nothing: {result.stdout!r}"
try:
yield sb
finally:
# ~115 MiB of tracked files plus its own git objects. pytest keeps the last three sessions'
# tmp dirs by default, so leaving it costs a third of a gigabyte on a developer machine that
# runs this a few times.
shutil.rmtree(sb, ignore_errors=True)
def test_the_reset_restores_the_BASELINE_even_after_a_proof_COMMITS(sandbox):
"""One sandbox serves every mutation, so the reset has to be a reset to a fixed point.
`git reset --hard` with no argument resets to whatever HEAD currently is. A proof test that
commits inside the sandbox several drive `git commit` for real moves HEAD onto a commit
carrying whatever was in the tree at the time, and every later reset would faithfully restore
THAT. The contamination surfaces as an unrelated red several mutations further on, which is the
hardest kind of harness defect to attribute.
"""
subject = sandbox / "docs" / "guard-inventory.md"
baseline = subject.read_text()
subject.write_text(baseline + "\n<!-- planted, then COMMITTED -->\n")
_git(sandbox, "add", "-A")
_git(sandbox, "commit", "-qm", "a proof test committing inside the sandbox")
assert subject.read_text() != baseline, "the planted change did not land, so this proves nothing"
reset_sandbox(sandbox)
assert subject.read_text() == baseline, (
"the reset restored the sandbox to a commit made DURING a mutation rather than to the "
"pristine baseline, so every later verdict is computed against a contaminated tree"
)
# Through `_git`, not a raw `subprocess.run`: an ambient `GIT_DIR` — which a git hook exports,
# and this suite runs from one — would resolve this against the REAL repository and compare two
# commits that have nothing to do with the sandbox. Asserting isolation with an unisolated call
# is the defect this harness exists to catch, one level up.
head = _git(sandbox, "rev-parse", "HEAD").stdout.decode().strip()
assert head == _BASELINES[str(sandbox.resolve())], f"HEAD was left off the recorded baseline commit: {head}"
@pytest.mark.parametrize("mutation", MUTATIONS, ids=lambda m: m.guard)
def test_MUTATION_the_declared_clause_reddens_the_named_proof(sandbox, mutation):
reset_sandbox(sandbox)
verdict = verify_mutation(sandbox, mutation)
assert verdict.ok, f"{mutation.guard}: {verdict.reason}"
_SURVIVORS = tuple(m for m in MUTATIONS if m.granularity == Mutation.DETECTOR)
@pytest.mark.parametrize("mutation", _SURVIVORS, ids=lambda m: m.guard)
def test_every_SURVIVING_clause_mutation_still_does(sandbox, mutation):
"""The DETECTOR grade, executed rather than recited.
A finer mutation that has since STARTED reddening the proof test means the guard now admits
clause-level proof and the entry should be regraded the coarse grade would otherwise persist as
an excuse long after the reason for it went away. Failing here is therefore good news; it just
has to be acted on.
"""
reset_sandbox(sandbox)
finer = replace(
mutation,
clause=mutation.survived_clause,
replacement=mutation.survived_replacement,
survived_clause="",
survived_replacement="",
)
verdict = verify_mutation(sandbox, finer)
assert not verdict.ok, (
f"{mutation.guard} is graded DETECTOR because {mutation.survived_clause!r} was tried and left "
"the proof test green — but it reddens it now. Regrade the entry to CLAUSE with that mutation."
)
assert "still PASSED" in verdict.reason, (
f"the survivor did not survive for the recorded reason — it failed with: {verdict.reason}"
)
# ------------------------------------------------------------------------------------------------
# THIS GUARD'S OWN MUTATION PROOF — the redness clause, disarmed, on a sandbox of two files
# ------------------------------------------------------------------------------------------------
EXIT_STATUS_CLAUSE = " if result.returncode != 1:"
DIAGNOSTIC_CLAUSE = " if mutation.expect not in diagnostic:"
LIB = REPO_ROOT / "scripts" / "tests" / "mutation_harness_lib.py"
def _inert_sandbox(tmp_path: Path, body: str = " assert True\n") -> tuple[Path, Mutation]:
"""A minimal synthetic sandbox holding one test file, whose outcome is fixed by `body`.
Deliberately not a copy of the repo: the subject here is `verify_mutation`'s verdict, and a real
sandbox would cost four seconds to prove something about two lines of control flow.
"""
tests = tmp_path / "scripts" / "tests"
tests.mkdir(parents=True)
(tests / "test_inert.py").write_text(f"# INERT MARKER\n\n\ndef test_ok():\n{body}")
return tmp_path, Mutation(
guard="inert",
target="scripts/tests/test_inert.py",
clause="# INERT MARKER",
replacement="# INERT MARKER, CHANGED",
proof="test_inert.py::test_ok",
granularity=Mutation.CLAUSE,
# A legal expectation, not the empty string: an empty one is a shape the manifest forbids,
# so a fixture relying on it would be proving something about a configuration that cannot
# ship. Each caller that needs a different one passes it through `replace`.
expect="a diagnostic no run of this fixture produces",
why="an inert mutation: the named test cannot notice it",
)
def _lib_with(tmp_path: Path, clause: str, replacement: str, label: str):
"""Import a copy of the library with one clause replaced.
The copy is registered in `sys.modules` before execution: `@dataclass` resolves a string
annotation through `sys.modules[cls.__module__]`, so an unregistered module raises AttributeError
on the first dataclass it defines rather than on anything to do with the clause.
"""
source = LIB.read_text()
assert source.count(clause) == 1, (
f"the {label} has moved or been reworded; RETARGET this mutation rather than loosening the "
"match — and update the mutation_manifest entry for this file, which names the same string"
)
path = tmp_path / f"mutant_{label.replace(' ', '_')}.py"
path.write_text(source.replace(clause, replacement, 1))
spec = importlib.util.spec_from_file_location(path.stem, path)
mutant = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mutant
try:
spec.loader.exec_module(mutant)
finally:
sys.modules.pop(spec.name, None)
return mutant
def test_an_INERT_mutation_is_REPORTED_rather_than_passed(tmp_path):
"""The behavioural half. A harness that cannot tell a real disarm from a comment edit would
report every row verified on any tree at all."""
sb, inert = _inert_sandbox(tmp_path)
verdict = verify_mutation(sb, inert)
assert not verdict.ok, "a mutation the named test cannot possibly notice was reported as verified"
assert "still PASSED" in verdict.reason, verdict.reason
def test_a_red_for_the_WRONG_REASON_is_not_accepted(tmp_path):
"""The gate `expect` exists for. Pytest reports an ordinary exception exactly as it reports a
failed assertion, so a mutation that CRASHES the proof test looks identical to one it detected.
A verdict that cannot tell those apart certifies rows on evidence about nothing."""
sb, inert = _inert_sandbox(tmp_path, " raise RuntimeError('an unrelated crash')\n")
crashing = replace(inert, expect="the diagnostic this row is supposed to produce")
verdict = verify_mutation(sb, crashing)
assert not verdict.ok, "a red with nothing to do with the declared diagnostic was accepted"
assert "NOT with the declared diagnostic" in verdict.reason, verdict.reason
def test_MUTATION_disarming_the_DIAGNOSTIC_gate_accepts_a_red_for_the_wrong_reason(tmp_path):
"""The clause-level proof, on the newer of the two gates every verdict passes through.
Disarm the check that the failure carries the declared diagnostic, and the crashing case above
must start reporting as verified. If it does not, that rejection is coming from somewhere other
than the clause, and the test above proves nothing about it.
"""
sb, inert = _inert_sandbox(tmp_path, " raise RuntimeError('an unrelated crash')\n")
crashing = replace(inert, expect="the diagnostic this row is supposed to produce")
assert not verify_mutation(sb, crashing).ok, (
"the UNMUTATED verdict already accepted it, so the mutant proves nothing"
)
mutant = _lib_with(tmp_path, DIAGNOSTIC_CLAUSE, " if False:", "diagnostic gate")
assert mutant.verify_mutation(sb, crashing).ok, (
"the diagnostic gate was replaced with a constant and a red for an unrelated reason was "
"STILL rejected, so the verdict does not hang on the clause that reads it"
)
def test_MUTATION_disarming_the_EXIT_STATUS_gate_accepts_a_run_that_NEVER_RAN_A_TEST(tmp_path):
"""The same proof for the older gate, and it has to be built carefully to isolate it.
A GREEN run cannot serve: it produces no exception output, so only an EMPTY expectation would
reach the status gate and an empty expectation is a shape the manifest forbids, which would
make this a proof about a configuration that cannot ship. Instead the sandbox's test file fails
at IMPORT: pytest exits non-1 (nothing was collected, so nothing ran) while still printing the
exception, so a legal non-empty expectation matches and the exit status is the ONLY thing
rejecting it. That is the case this gate exists for a proof ref that no longer names a
collectable test must not read as a guard going red.
"""
boom = "a deliberate import-time failure, which is not a test result"
sb, inert = _inert_sandbox(tmp_path)
(sb / "scripts" / "tests" / "test_inert.py").write_text(f"# INERT MARKER\nraise RuntimeError({boom!r})\n")
uncollectable = replace(inert, expect=boom)
verdict = verify_mutation(sb, uncollectable)
assert not verdict.ok, "a run in which no test executed was accepted as a guard going red"
assert len(uncollectable.expect) >= 20, "the expectation must be one the manifest would accept"
mutant = _lib_with(tmp_path, EXIT_STATUS_CLAUSE, " if False:", "exit status gate")
assert mutant.verify_mutation(sb, uncollectable).ok, (
"the exit-status gate was replaced with a constant and a run that never executed a test was "
"still rejected, so the verdict does not hang on the status it reads"
)
def test_a_clause_that_no_longer_OCCURS_ONCE_is_reported_rather_than_applied(tmp_path):
"""The failure paths, driven directly, because this is where a harness quietly stops harnessing.
A clause that has been reworded away, or that now matches a second site, must produce a verdict
naming the problem. Silently replacing nothing or replacing the wrong site would leave every
row in this file reporting verified while mutating something nobody declared.
"""
sb, inert = _inert_sandbox(tmp_path)
gone = replace(inert, clause="# A CLAUSE THAT IS NOT THERE")
assert not verify_mutation(sb, gone).ok
assert "occurs 0 times" in verify_mutation(sb, gone).reason
(sb / "scripts" / "tests" / "test_inert.py").write_text(
"# INERT MARKER\n# INERT MARKER\n\n\ndef test_ok():\n assert True\n"
)
assert "occurs 2 times" in verify_mutation(sb, inert).reason
absent = replace(inert, target="scripts/tests/no_such_file.py")
assert "does not exist in the sandbox" in verify_mutation(sb, absent).reason
def test_the_sandbox_is_left_UNCHANGED_by_a_verdict(tmp_path):
"""One sandbox serves every mutation, so a verdict that leaves its edit behind would make each
result a function of the ones before it."""
sb, inert = _inert_sandbox(tmp_path)
subject = sb / "scripts" / "tests" / "test_inert.py"
before = subject.read_text()
verify_mutation(sb, inert)
assert subject.read_text() == before, "verify_mutation left its mutation in the sandbox"
@@ -1,821 +0,0 @@
"""#807 guard: every schema that can SILENTLY DROP a member on a SPA write has a stated disposition.
WHAT THIS BLOCKS. A request-body property that is absent from its schema's `required` array emits
into `web/src/api/generated/v1.d.ts` as an OPTIONAL member (`"weight"?: number`). A SPA builder may
then omit it, `tsc` says nothing optional means omittable, by design and on a FULL-REPLACE write
the server stores the field's default. That is #754's mechanism and it is what #807 found live in
`MultiCollectionItemRequest.weight` and `UpdateFFmpegProfileRequest.qsvPreferNativeDecoder`.
WHY IT IS A DERIVED GUARD AND NOT A TABLE IN A DOC. #807 shipped the disposition list by hand
TWICE and got it wrong BOTH times, each time by sorting a schema on its NAME rather than on what its
endpoint does:
round 1 a prose sentence exempted "create/update" `updateMultiCollection` and
`updateFFmpegProfile` are full replaces, and both were live silent drops.
round 2 a hand-written table replaced that sentence and omitted `ArtworkContentTypeModel`,
because `Model` reads as a response model. It is reachable from `PUT /channels/{id}`.
Two misses from one mechanism, so the mechanism goes rather than the list getting a third patch.
`testing.guard-derives-population-from-source` is explicit that a hand-written list is "a filter
frozen at authoring time, correct on the day it was written and unable to report the day it stopped
being" — and unlike #820's population (sites in code, which needs compiler-API tooling), THIS
population has an authoritative machine-readable source: the OpenAPI document.
SCOPE vs POPULATION, per that same record. The POPULATION which schemas can drop a member on a
write is DERIVED here, every run, from `ErsatzTV/wwwroot/openapi/v1.json`. The DISPOSITIONS below
are the SCOPE: a reviewed policy choice per schema, legitimately hand-written, and each one is
FORCED to exist by the set-equality assertion. A new optional member in a named component schema
reachable from a request body, or in an inline request body, fails this test until someone writes
down what should happen about it that is the reach, bounded by what `_resolve`
resolves. That resolver walks `allOf`, `oneOf`, `anyOf`, `if`/`then`/`else`, `dependentSchemas`,
`items`/`prefixItems` and inline objects under `properties`, and deliberately contributes nothing
for `additionalProperties`/`patternProperties` (which name no fixed members) each pinned by a
case in `test_composition_is_resolved_the_way_JSON_Schema_means_it`. It does NOT follow `$ref`;
that is the component walk's job. An earlier draft said "anywhere in the request graph"; inline bodies were invisible at
the time, so the universal was false the day it was written.
Set equality is asserted in BOTH directions and reported separately, because they are opposite
defects: `missing` is a schema the API can drop and nobody has ruled on (the #807 defect), `phantom`
is a disposition for a schema that no longer has an optional member reachable from a request body
(the registry claiming coverage of something gone).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
OPENAPI = REPO_ROOT / "ErsatzTV" / "wwwroot" / "openapi" / "v1.json"
# --- the closed disposition vocabulary -------------------------------------------------------
#
# COVERED the SPA builds this body and the builder is annotated `Complete<T>`, so omitting a
# member is a typecheck error (`web/src/api/completeRequest.ts`).
# CREATE a POST that creates a new entity, where an omitted member correctly means "use the
# default". Annotating it would be a BUG, not coverage — see the from-lineup note.
# TRIGGER the body parameterises an ACTION and replaces no stored entity, so there is nothing to
# drop.
# COMPUTED the optional members are get-only computed properties on the C# record. System.Text.Json
# never deserializes them, so the client cannot drop a stored value by omitting them —
# and `Complete<T>` must NOT be applied here, because it would force a caller to invent
# server-computed values in an outbound request.
COVERED = "COVERED"
CREATE = "CREATE"
TRIGGER = "TRIGGER"
COMPUTED = "COMPUTED"
DISPOSITIONS: dict[str, tuple[str, str]] = {
"UpdateFFmpegProfileRequest": (
COVERED,
"PUT /ffmpeg/profiles/{id} is a full replace. `qsvPreferNativeDecoder` is a defaulted ctor "
"param so ASP.NET drops it from `required`. Was a LIVE silent drop before #807.",
),
"CreateFFmpegProfileRequest": (
COVERED,
"FFmpegProfilesScreen's `Draft` feeds BOTH the POST and the full-replace PUT, so the draft "
"type itself is `Complete<…>` rather than only the update wrapper.",
),
"MultiCollectionItemRequest": (
COVERED,
"Nested in the full-replace PUT /multi-collections/{id}. `weight` is a defaulted ctor param "
"(`CreateMultiCollectionRequest.cs`). Was a LIVE silent drop before #807: the screen carried "
"a prose comment warning that dropping it resets every weight to 1, and a comment is not a "
"check.",
),
"ArtworkContentTypeModel": (
COMPUTED,
"Reachable from the full-replace PUT /channels/{id} (via `UpdateChannelRequest.logo`), so "
"the endpoint test alone would put it in COVERED. It is not: `IsExternalUrl`, "
"`HasContentType` and `UrlWithContentType` are computed get-only properties on the record "
"`ArtworkContentTypeModel(string Path, string ContentType)`, never deserialized, so a "
"client omitting them drops nothing. Annotating the SPA site `Complete<…>` would force it "
"to fabricate server-computed values. This row exists because #807's hand-written table "
"omitted this schema on the strength of its `…Model` name.",
),
"AutoTunedChannelRequest": (
CREATE,
"POST /channels/auto-tune. `CreateChannelFromLineupHandler` does `Channels.Add(...)` and "
"rejects a duplicate number; it never overwrites an existing channel.",
),
"CreateChannelFromLineupAdvancedOptionsRequest": (
CREATE,
"POST /channels/from-lineup. Omission is LOAD-BEARING here: `CreateChannelFromLineupClearField` "
"documents that for the template-inheritable fields a null/omitted override means INHERIT the "
"template value, with a separate explicit `clear` list to force NONE. `Complete<T>` would "
"collapse that third state into explicit-null.",
),
"AutoTuneSourceWeightRequest": (
CREATE,
"Nested in the auto-tune POST body; same create-time semantics as its parent.",
),
"POST /api/v1/artwork/uploads (multipart/form-data inline body)": (
TRIGGER,
"A multipart upload. Its body is declared INLINE rather than as a named schema, which is why "
"it needs a key of this shape at all. `file` and `target` sit outside `required`, but the "
"endpoint stores an uploaded blob and replaces no entity, so there is no stored value for an "
"omitted member to overwrite; `web/src/api/artwork.ts` builds it as hand-rolled `FormData` "
"with no generated type involved.",
),
"ScanShowRequest": (
TRIGGER,
"POST /libraries/{id}/scan-show starts a scan. `deepScan` parameterises the action; no entity is replaced.",
),
}
# Anti-vacuity floors. A completeness check whose population came back empty must not report that it
# proved everything (`testing.guard-ships-with-mutation-proof`). These are LOWER bounds on the
# derived intermediates, deliberately well under today's values (142 reachable, 13 with optional
# members) so ordinary schema churn does not trip them — they exist to catch a broken parse or a
# `$ref` walk that reached nothing, not to pin the corpus.
MIN_REQUEST_REACHABLE_SCHEMAS = 40
MIN_SCHEMAS_WITH_OPTIONAL_MEMBERS = 5
def _load() -> dict:
if not OPENAPI.is_file():
pytest.fail(f"{OPENAPI.relative_to(REPO_ROOT)} is missing — run scripts/update-openapi.sh")
return json.loads(OPENAPI.read_text())
def _schema_refs(node: object, out: set[str]) -> None:
"""Collect every `#/components/schemas/X` name anywhere under `node`."""
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
out.add(ref.rsplit("/", 1)[1])
for value in node.values():
_schema_refs(value, out)
elif isinstance(node, list):
for value in node:
_schema_refs(value, out)
def _request_reachable(doc: dict) -> set[str]:
"""Every schema reachable from ANY operation's request body, transitively.
Two deliberate non-restrictions, both because this population has now been drawn by hand wrongly
twice and every hand-drawn edge is a place to be wrong again:
NO VERB ALLOW-LIST. An earlier draft scanned POST/PUT/PATCH, which reads as obviously right and
already had an exception: `DELETE /api/v1/media-items` carries a request body. Rather than argue
that a DELETE body cannot cause a full-replace drop probably true, and exactly the kind of
"probably" that produced this record's two live misses — every operation carrying a request body
seeds the walk, and anything it surfaces must acquire a stated disposition.
TRANSITIVE, and that is load-bearing rather than thorough: `MultiCollectionItemRequest` and
`ArtworkContentTypeModel` are both NESTED, so a check reading only top-level request bodies
would have missed both of the schemas this guard exists because of. `_schema_refs` walks every
value of every dict and every list element, so `oneOf` (23 occurrences today), `items`,
`additionalProperties` and any future composition keyword are covered without naming them.
"""
schemas = doc["components"]["schemas"]
seeds: set[str] = set()
for operations in doc["paths"].values():
for operation in operations.values():
if isinstance(operation, dict) and operation.get("requestBody"):
_schema_refs(operation["requestBody"], seeds)
seen: set[str] = set()
stack = list(seeds)
while stack:
name = stack.pop()
if name in seen or name not in schemas:
continue
seen.add(name)
nested: set[str] = set()
_schema_refs(schemas[name], nested)
stack.extend(nested - seen)
return seen
def _optional_of(schema: object) -> set[str]:
"""The members of `schema` a client may omit, nested inline objects included.
This is the function to call. `_resolve` below is its recursive half and returns three sets;
the split matters and is the fix for a real defect, so it is stated here rather than in a
comment further down.
A nested inline object lives in its OWN namespace. Qualifying its members as `parent.child`
BEFORE subtracting `required` merges the two namespaces, and a literal member named
`parent.child` then collides with the nested one masking it entirely when the literal is
required, so a droppable member vanishes from the population with nothing failing (measured
2026-08-23). Subtracting inside each namespace first and qualifying only the survivors means
the two sets are never mixed, so the collision cannot arise and there is no separator to
defend. An earlier version instead ASSERTED that no property name contains a dot, which is a
guard where a restructure was available.
Residual, stated because it is real: if a literal `a.b` and a nested `a` -> `b` are BOTH
optional they conflate into one reported string. That is a diagnostic ambiguity, not a miss
the schema still enters the population and still forces a disposition.
"""
properties, required, nested_optional = _resolve(schema)
return (properties - required) | nested_optional
def _resolve(schema: object) -> tuple[set[str], set[str], set[str]]:
"""(properties, required, already-resolved nested optional members) for one schema node.
The third set is carried through every composition site rather than merged into the first two,
for the namespace reason in `_optional_of`. Do NOT collapse this back to a 2-tuple to spare the
callers: folding `nested_optional` into `properties` re-creates the mask at the merge boundary
and the whole suite stays green while it does.
NESTING. Handling `allOf` one level deep misses an `allOf` inside an `allOf` a property there
reported as no properties at all. Composition nests, so the resolution has to recurse.
`$ref` IS DELIBERATELY NOT RESOLVED HERE, and that is not a hole: an arm that is a `$ref` names
a component schema, which `_request_reachable` already seeds on (`_schema_refs` finds a `$ref`
anywhere, arms included) and `_optional_members` already walks, so its optional members surface
under their OWN key rather than being merged into the inline one. Verified by construction in
`test_a_ref_ARM_is_covered_by_the_component_walk_not_by_this_one`. Resolving refs here as well
would report the same member twice under two keys, which is worse than either.
CONJUNCTION vs DISJUNCTION. `allOf` arms ALL apply, so their `required` sets UNION. `oneOf` and
`anyOf` arms are ALTERNATIVES, so a member is only genuinely required when EVERY alternative
requires it the `required` sets INTERSECT. Unioning them instead (the first version) marks a
member required because one arm requires it, hiding the arm that lets a client omit it. That is
the drop this whole guard exists to catch, so getting it backwards is not a detail.
No cycle guard, deliberately. This resolver never follows `$ref`, so the only way to recurse
forever is a schema that contains ITSELF by object identity which `json.load` cannot produce,
since JSON has no back-references. Verified 2026-08-23: a hand-built self-referential dict does
raise `RecursionError`, a LOUD red rather than a silent wrong answer. The `$ref` cycle a real
document CAN express is handled by `_request_reachable`'s `seen` set, also verified.
"""
if not isinstance(schema, dict):
return set(), set(), set()
properties = set(schema.get("properties") or {})
required = set(schema.get("required") or [])
nested_optional: set[str] = set()
# A property may itself be an INLINE OBJECT rather than a `$ref`, and the generator recurses
# into it (`objectTypeFromSchema` -> `typeFromSchema`), so a member outside that nested
# `required` really does emit `?:` and really is droppable. Resolved in its own namespace and
# qualified afterwards — see `_optional_of`.
for name, value in (schema.get("properties") or {}).items():
if not isinstance(value, dict) or "$ref" in value:
continue
nested_optional |= {f"{name}.{child}" for child in _optional_of(value)}
# CONJUNCTIVE: every `allOf` arm applies, so both sets union.
for branch in schema.get("allOf") or []:
branch_properties, branch_required, branch_nested = _resolve(branch)
properties |= branch_properties
required |= branch_required
nested_optional |= branch_nested
# CONDITIONAL keywords — `if`, `then`, `else`, `dependentSchemas`. All four are treated the same
# way: collect their properties (a client may send them) and DISCARD their `required` (it binds
# only on a branch that may not be taken, so the member is omittable).
#
# An earlier version put `then`/`else` in the conjunctive list above, unioning their `required`.
# That is the polarity error this function warns about above, committed in the same block:
# `then` and `else` are MUTUALLY EXCLUSIVE, so a member required only under `then` is omittable
# whenever `if` does not match, and the guard reported it as required — the silent-miss
# direction, which is the one this whole file exists to catch. `if`'s `required` is discarded
# for a different reason (it selects a branch rather than obliging anyone), and
# `dependentSchemas` for a third (it binds only when its trigger key is present), but the
# resulting rule is identical, so they share one loop rather than three arguments.
conditional: list[object] = []
for keyword in ("if", "then", "else"):
value = schema.get(keyword)
conditional.extend(value if isinstance(value, list) else ([value] if isinstance(value, dict) else []))
conditional.extend((schema.get("dependentSchemas") or {}).values())
for branch in conditional:
branch_properties, _, branch_nested = _resolve(branch)
properties |= branch_properties
nested_optional |= branch_nested
# DISJUNCTIVE keywords, handled SEPARATELY rather than concatenated: `oneOf` and `anyOf` are
# conjunctive WITH EACH OTHER (a body satisfying both must satisfy one arm of each), so the
# correct required set is the intersection within each keyword, unioned across them. Merging
# the two lists first intersects across keywords and under-reports required.
for keyword in ("oneOf", "anyOf"):
alternatives = schema.get(keyword) or []
shared_required: set[str] | None = None
for branch in alternatives:
branch_properties, branch_required, branch_nested = _resolve(branch)
properties |= branch_properties
nested_optional |= branch_nested
shared_required = branch_required if shared_required is None else (shared_required & branch_required)
if shared_required:
required |= shared_required
# An inline body may be an ARRAY of inline objects; the members live on `items`, and a member
# droppable there is droppable in the request. `items` is a SCHEMA in OpenAPI 3.1 / JSON Schema
# 2020-12 and may be a LIST in the 3.0 tuple form, so both shapes are walked — and `prefixItems`
# is the 2020-12 spelling of that tuple.
for keyword in ("items", "prefixItems"):
value = schema.get(keyword)
branches = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
for branch in branches:
branch_properties, branch_required, branch_nested = _resolve(branch)
properties |= branch_properties
required |= branch_required
nested_optional |= branch_nested
return properties, required, nested_optional
def _optional_members(doc: dict) -> dict[str, list[str]]:
"""Schema -> its properties that sit OUTSIDE `required`, i.e. the ones that emit `?:`."""
out: dict[str, list[str]] = {}
for name, schema in doc["components"]["schemas"].items():
optional = sorted(_optional_of(schema))
if optional:
out[name] = optional
return out
def _inline_body_members(doc: dict) -> dict[str, list[str]]:
"""Optional members of request bodies declared INLINE, i.e. with no `$ref` to a named schema.
`_optional_members` iterates `components.schemas`, and `_request_reachable` seeds from `$ref`s,
so between them an inline body is invisible in BOTH directions. That was not hypothetical: the
document declares one today (`POST /api/v1/artwork/uploads`, multipart), whose `file` and
`target` sit outside any `required` array a member already present and outside the guard's
reach while its docstring claimed to cover the whole request graph.
Keyed by `"<VERB> <path> (<media type> inline body)"` rather than by a schema name, because there is no name
to use which is exactly why the component-schema walk cannot see it.
Composition is resolved by `_optional_of`/`_resolve`, which recurse and treat `allOf` as
conjunction and `oneOf`/`anyOf` as alternatives this body splits its properties across `allOf`
arms and would otherwise report none.
"""
out: dict[str, list[str]] = {}
for path, operations in doc["paths"].items():
for verb, operation in operations.items():
if not isinstance(operation, dict) or not operation.get("requestBody"):
continue
for content_type, media in (operation["requestBody"].get("content") or {}).items():
schema = media.get("schema") or {}
if "$ref" in schema:
# Belt-and-braces, not load-bearing: `_optional_of` returns an empty
# sets for a bare `$ref` node anyway, so deleting this line changes no result
# today. It stays because a body that names a component schema is that schema's
# business — `_optional_members` already covers it — and skipping it here keeps
# that division explicit rather than accidental.
continue
optional = sorted(_optional_of(schema))
if optional:
# Keyed by MEDIA TYPE as well as verb and path. An operation may declare more
# than one inline body (a second `[Consumes]` is all it takes), and keying on
# verb+path alone made the later one overwrite the earlier — a droppable member
# silently disappearing from the population rather than failing.
out[f"{verb.upper()} {path} ({content_type} inline body)"] = optional
return out
def _droppable(doc: dict) -> dict[str, list[str]]:
reachable = _request_reachable(doc)
droppable = {n: m for n, m in _optional_members(doc).items() if n in reachable}
droppable.update(_inline_body_members(doc))
return droppable
def test_the_derivation_reached_a_real_population() -> None:
"""Anti-vacuity: a broken `$ref` walk or parse must not read as 'nothing to rule on'.
What this test does NOT do, measured 2026-08-22 rather than assumed: it does not catch a
PARTIALLY broken walk. Deleting the transitive step from `_request_reachable` so only the
schemas named directly on a request body resolve leaves both floors satisfied and this test
GREEN. What reddens is `test_MUTATION_a_planted_optional_member_is_reported_as_MISSING`, whose
planted schema is reached through a nested `$ref` precisely so that it can, plus the disposition
test (the nested rows vanish and report as PHANTOM). So the floors below are the crude backstop
against a parse that reached nothing at all; the planted-member test is what actually holds the
walk honest, and it should be the one kept working if these two ever conflict.
"""
doc = _load()
reachable = _request_reachable(doc)
optional = _optional_members(doc)
assert len(reachable) >= MIN_REQUEST_REACHABLE_SCHEMAS, (
f"only {len(reachable)} schemas reachable from a request body — the $ref walk is broken, not the API"
)
assert len(optional) >= MIN_SCHEMAS_WITH_OPTIONAL_MEMBERS, (
f"only {len(optional)} schemas have a property outside `required` — suspect the parse"
)
def test_the_walks_ASSUMPTIONS_about_the_document_still_hold() -> None:
"""The derivation's scope mirrors two properties of the OpenAPI document. Check them.
`testing.guard-derives-population-from-source`: "When the scope itself MIRRORS an authoritative
source, the mirror needs its own equality check or a dated staleness marker, or the guard is
complete within a scope that has silently gone stale." Two such assumptions are baked into
`_request_reachable`, and both are true of the document today (2026-08-22) rather than
guaranteed by anything:
1. Request bodies are declared INLINE on the operation. If ASP.NET ever emits a
`components.requestBodies` bucket and operations `$ref` into it, the seed walk still finds
the `$ref` but only because `_schema_refs` collects `#/components/schemas/...` names, so a
body referencing `#/components/requestBodies/X` would seed NOTHING and the schemas under it
would drop out of the population silently.
2. Every `$ref` in the document points into `#/components/schemas/`. `_schema_refs` matches on
that prefix, so a ref into any other bucket is invisible to it.
Both are cheap to assert and neither is asserted anywhere else, so a change in the emitter
would otherwise shrink this guard's population without failing anything.
A THIRD assumption used to sit here unstated and was already violated: that every request body
`$ref`s a named component schema. `POST /api/v1/artwork/uploads` declares its body inline, so
both `_optional_members` (which iterates `components.schemas`) and `_request_reachable` (which
seeds from `$ref`s) were blind to it. That one is not an assumption any more
`_inline_body_members` handles it which is why it is described here rather than asserted.
"""
doc = _load()
buckets = set(doc.get("components", {}))
assert "requestBodies" not in buckets, (
"the OpenAPI document now declares components.requestBodies — `_request_reachable` seeds "
"only from inline operation bodies and `_schema_refs` only follows #/components/schemas/, "
"so schemas behind a shared request body are now INVISIBLE to this guard. Teach the walk "
"to resolve that bucket before deleting this assertion."
)
ref_buckets = set(re.findall(r'"#/components/([^/"]+)/', json.dumps(doc)))
assert ref_buckets <= {"schemas"}, (
f"$refs now point into {sorted(ref_buckets - {'schemas'})} as well as schemas; "
"`_schema_refs` matches only the schemas prefix and silently ignores the rest"
)
def test_every_droppable_request_schema_has_a_stated_disposition() -> None:
"""Set equality, both directions, accumulated into ONE message.
Failing fast on the first mismatch hands back one schema at a time and invites fixing them one
at a time, which is how #754's twin stayed hidden.
"""
droppable = _droppable(_load())
unruled = sorted(set(droppable) - set(DISPOSITIONS))
phantom = sorted(set(DISPOSITIONS) - set(droppable))
problems: list[str] = []
if unruled:
problems.append(
"MISSING — reachable from a request body with a member outside `required`, and "
"no disposition written down. Decide what happens to each and add a row:\n"
+ "\n".join(f" {n}: optional members {droppable[n]}" for n in unruled)
)
if phantom:
problems.append(
"PHANTOM — a disposition for a schema that is no longer request-reachable with an "
"optional member. Delete the row rather than leaving it claiming coverage:\n"
+ "\n".join(f" {n}" for n in phantom)
)
assert not problems, "\n\n".join(problems)
@pytest.mark.parametrize(
("case", "schema", "expected"),
[
(
"a flat schema reports the properties outside `required`",
{"type": "object", "properties": {"a": {}, "b": {}}, "required": ["a"]},
["b"],
),
(
"allOf arms are conjunctive: each arm's `required` applies",
{"allOf": [{"properties": {"a": {}}}, {"properties": {"b": {}}, "required": ["b"]}]},
["a"],
),
(
"allOf NESTED inside allOf is reached — a one-level walk reported nothing here",
{"allOf": [{"allOf": [{"properties": {"deep": {}}}]}]},
["deep"],
),
(
"oneOf arms are ALTERNATIVES: required in one arm only means a client may omit it",
{"oneOf": [{"properties": {"x": {}}, "required": ["x"]}, {"properties": {"x": {}}}]},
["x"],
),
(
"oneOf where EVERY arm requires it is genuinely required",
{
"oneOf": [
{"properties": {"x": {}}, "required": ["x"]},
{"properties": {"x": {}}, "required": ["x"]},
]
},
[],
),
(
"anyOf arms are alternatives too — required in one arm only means droppable",
{"anyOf": [{"properties": {"y": {}}, "required": ["y"]}, {"properties": {"y": {}}}]},
["y"],
),
(
"anyOf where EVERY arm requires it is genuinely required",
{
"anyOf": [
{"properties": {"y": {}}, "required": ["y"]},
{"properties": {"y": {}}, "required": ["y"]},
]
},
[],
),
(
"oneOf and anyOf on ONE node are conjunctive with EACH OTHER, not one alternative list",
{
"oneOf": [{"properties": {"x": {}}, "required": ["x"]}, {"properties": {"x": {}}, "required": ["x"]}],
"anyOf": [{"properties": {"y": {}}, "required": ["y"]}, {"properties": {"y": {}}, "required": ["y"]}],
},
[],
),
(
"composition NESTED inside an alternative arm is reached",
{"oneOf": [{"allOf": [{"properties": {"nestedInArm": {}}}]}]},
["nestedInArm"],
),
(
"properties in arms 2..n are collected, not just the first arm's",
{"oneOf": [{"properties": {"first": {}}}, {"properties": {"second": {}}}]},
["first", "second"],
),
(
"a top-level `required` still applies when alternatives are present",
{"properties": {"top": {}, "other": {}}, "required": ["top"], "oneOf": [{"properties": {"arm": {}}}]},
["arm", "other"],
),
(
"an inline body that is an ARRAY of objects exposes its item members",
{"type": "array", "items": {"properties": {"itemReq": {}, "itemOpt": {}}, "required": ["itemReq"]}},
["itemOpt"],
),
(
"`items` in the 3.0 TUPLE form (a list) is walked, not just the schema form",
{"type": "array", "items": [{"properties": {"tupleReq": {}, "tupleOpt": {}}, "required": ["tupleReq"]}]},
["tupleOpt"],
),
(
"`prefixItems`, the 2020-12 spelling of a tuple, is walked",
{"type": "array", "prefixItems": [{"properties": {"prefixOpt": {}}}]},
["prefixOpt"],
),
(
"nested arrays are followed to the object at the bottom",
{"type": "array", "items": {"type": "array", "items": {"properties": {"deepOpt": {}}}}},
["deepOpt"],
),
(
"`dependentSchemas` members are collectable and its conditional `required` is discarded",
{"dependentSchemas": {"trigger": {"properties": {"depOpt": {}}, "required": ["depOpt"]}}},
["depOpt"],
),
(
"a `required` under `then` does NOT make a member required — the branch may not be taken",
{
"if": {"properties": {"k": {}}, "required": ["k"]},
"then": {"properties": {"m": {}}, "required": ["m"]},
},
["k", "m"],
),
(
"a `required` under `else` does not either, and `then`/`else` are mutually exclusive",
{
"if": {"properties": {"k": {}}},
"then": {"properties": {"m": {}}, "required": ["m"]},
"else": {"properties": {"m": {}}},
},
["k", "m"],
),
(
"a `required` under `if` selects a branch, it does not oblige the client",
{"if": {"properties": {"k": {}}, "required": ["k"]}, "then": {"properties": {"m": {}}}},
["k", "m"],
),
(
"an INLINE OBJECT under `properties` is recursed into, reported with a dotted path",
{
"properties": {
"top": {},
"nested": {"properties": {"a": {}, "b": {}}, "required": ["a"]},
},
"required": ["top", "nested"],
},
["nested.b"],
),
(
"a REQUIRED literal `a.b` cannot mask a nested `a` -> `b` — the namespaces never merge",
{"properties": {"a.b": {}, "a": {"properties": {"b": {}}}}, "required": ["a.b", "a"]},
["a.b"],
),
(
"`prefixItems` positions are conjunctive, so a `required` there really does bind",
{
"type": "array",
"prefixItems": [{"properties": {"pReq": {}, "pOpt": {}}, "required": ["pReq"]}],
},
["pOpt"],
),
(
"a dotted nested member cannot be confused with a top-level member of the same name",
{
"properties": {"b": {}, "nested": {"properties": {"b": {}}}},
"required": ["b", "nested"],
},
["nested.b"],
),
(
"`patternProperties` names no FIXED members, so it contributes none — same as additionalProperties",
{"properties": {"named": {}}, "patternProperties": {"^x-": {"properties": {"notAMember": {}}}}},
["named"],
),
(
"`additionalProperties` names no members, so it contributes none",
{"properties": {"named": {}}, "additionalProperties": {"properties": {"notAMember": {}}}},
["named"],
),
(
"`not` cannot make a member required",
{"properties": {"a": {}}, "not": {"required": ["a"]}},
["a"],
),
(
"properties declared under `else` count for the same reason `then` does",
{"if": {}, "else": {"properties": {"elseOpt": {}}}},
["elseOpt"],
),
(
"properties declared under `then` are reachable on some branch, so they count",
{"if": {"properties": {"kind": {}}}, "then": {"properties": {"conditional": {}}}},
["conditional", "kind"],
),
(
"top-level properties and an allOf arm are merged, not either/or",
{"properties": {"top": {}}, "required": ["top"], "allOf": [{"properties": {"inner": {}}}]},
["inner"],
),
],
)
def test_composition_is_resolved_the_way_JSON_Schema_means_it(case: str, schema: dict, expected: list[str]) -> None:
"""Pin `_optional_of`/`_resolve` against constructed schemas, not against today's document.
The document exercises exactly one shape (a two-arm `allOf` in the artwork upload body), so
every other branch of this resolver would otherwise be unexercised prose. Both of the first
version's defects are here as cases: the nested `allOf` it could not reach, and the `oneOf`
whose `required` it unioned instead of intersecting which marked a member required because
ONE arm required it, hiding the arm that lets a client drop it.
"""
assert sorted(_optional_of(schema)) == expected, case
_NESTED_PROBE = {
"properties": {"outer": {"properties": {"req": {}, "opt": {}}, "required": ["req"]}},
"required": ["outer"],
}
@pytest.mark.parametrize(
("site", "schema"),
[
("top level", _NESTED_PROBE),
("allOf arm", {"allOf": [_NESTED_PROBE]}),
("if", {"if": _NESTED_PROBE}),
("then", {"then": _NESTED_PROBE}),
("else", {"else": _NESTED_PROBE}),
("dependentSchemas", {"dependentSchemas": {"trigger": _NESTED_PROBE}}),
("oneOf arm", {"oneOf": [_NESTED_PROBE]}),
("anyOf arm", {"anyOf": [_NESTED_PROBE]}),
("items", {"type": "array", "items": _NESTED_PROBE}),
("prefixItems", {"type": "array", "prefixItems": [_NESTED_PROBE]}),
],
)
def test_a_nested_inline_object_survives_EVERY_composition_site(site: str, schema: dict) -> None:
"""`_resolve` returns three sets, and the third has to be threaded through every branch.
A composition site that unions `properties` and `required` but forgets `nested_optional` loses
the nested member silently no error, just a smaller population and each site is a separate
opportunity to forget. Enumerating the sites here is what makes "threaded through every branch"
a checked property instead of a claim in a docstring; a NEW composition keyword must be added
to this list, and if it is not, the omission is at least visible in one place rather than
spread across the resolver.
"""
assert "outer.opt" in _optional_of(schema), (
f"a nested inline object under `{site}` lost its optional member — `nested_optional` is "
"not threaded through that branch of `_resolve`"
)
def test_a_ref_ARM_is_covered_by_the_component_walk_not_by_this_one() -> None:
"""The two walks COMPOSE; neither alone covers an inline body with a `$ref` arm.
`_resolve` does not resolve `$ref`, so an `allOf` arm that is a `$ref`
contributes nothing to the inline key. That looks like a gap and is not: the referenced schema
is a named component, so it is seeded by `_request_reachable` and walked by `_optional_members`,
and its optional members surface under their own key. Pinned here because the obvious "fix"
resolving refs in this resolver too would report the same member under two keys, and because
a reader checking only one of the two walks would reasonably conclude the case is uncovered.
"""
doc = _load()
doc["components"]["schemas"]["RefArmProbeSchema"] = {
"type": "object",
"properties": {"probeRequired": {"type": "string"}, "probeOptional": {"type": "string"}},
"required": ["probeRequired"],
}
doc["paths"]["/probe-ref-arm"] = {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"allOf": [
{"$ref": "#/components/schemas/RefArmProbeSchema"},
{"properties": {"probeInline": {}}},
]
}
}
}
}
}
}
droppable = _droppable(doc)
assert droppable.get("RefArmProbeSchema") == ["probeOptional"], (
"a $ref arm's target must still surface via the component walk — if this is empty, an "
"inline body can hide a droppable member behind a $ref"
)
assert droppable.get("POST /probe-ref-arm (application/json inline body)") == ["probeInline"]
def test_every_disposition_uses_the_closed_vocabulary() -> None:
"""A free-text disposition would let a row read as considered while saying nothing."""
allowed = {COVERED, CREATE, TRIGGER, COMPUTED}
for name, (disposition, why) in DISPOSITIONS.items():
assert disposition in allowed, f"{name}: {disposition!r} is not one of {sorted(allowed)}"
assert len(why.strip()) >= 40, f"{name}: the reason is too thin to be a decision"
def test_MUTATION_a_planted_optional_member_is_reported_as_MISSING() -> None:
"""The checker-guard mutation proof (`testing.guard-ships-with-mutation-proof`).
Disarming a checker makes it ABSENT rather than red, so the mutation goes into the guarded
ARTIFACT: plant a request-reachable schema carrying a member outside `required` and require the
derivation to surface it. Mutating the checker's own population instead would be the trap that
record names a shrunken population makes every real row report PHANTOM, so the proof would go
red on a false positive while saying nothing about the MISSING detection this row claims.
The planted schema is reached through a real request body, so this also exercises the
transitive `$ref` walk that the hand-written list twice failed to do by eye.
"""
doc = _load()
schemas = doc["components"]["schemas"]
# Deterministic, because `set` iteration order over strings varies per process: picking the
# host with `next(iter(...))` would silently vary WHICH walk depth the proof exercises from run
# to run, so a green run would not mean the same thing twice.
candidates = sorted(
n for n in _request_reachable(doc) if isinstance(schemas.get(n), dict) and schemas[n].get("properties")
)
assert candidates, "no request-reachable schema with properties — the walk is broken"
host = candidates[0]
schemas["PlantedDroppableRequest"] = {
"type": "object",
"properties": {"plantedRequired": {"type": "string"}, "plantedOptional": {"type": "string"}},
"required": ["plantedRequired"],
}
# Planted through a `oneOf` LIST rather than a bare dict `$ref`, because `_schema_refs` has two
# descent branches and only the dict one was exercised: deleting its list descent — which makes
# every `oneOf` reference invisible, 23 of them in the document today — left the whole file
# GREEN when measured 2026-08-22. A proof that cannot see half its own walk is the "green for
# the wrong reason" shape this repo keeps recording.
schemas[host]["properties"]["plantedLink"] = {
"oneOf": [{"type": "null"}, {"$ref": "#/components/schemas/PlantedDroppableRequest"}]
}
# Plant into the INLINE request body too, so `_inline_body_members` is exercised rather than
# merely present. Without this the whole inline branch could be deleted and every test here
# would stay green — the exact shape the `oneOf` list branch was in before it was planted
# through.
inline_host = next(
(
media["schema"]
for operations in doc["paths"].values()
for operation in operations.values()
if isinstance(operation, dict) and operation.get("requestBody")
for media in (operation["requestBody"].get("content") or {}).values()
if isinstance(media.get("schema"), dict) and "$ref" not in media["schema"]
),
None,
)
assert inline_host is not None, "no inline request body in the document — re-target this plant"
inline_host.setdefault("properties", {})["plantedInlineOptional"] = {"type": "string"}
droppable = _droppable(doc)
planted_inline = [k for k, v in droppable.items() if "plantedInlineOptional" in v]
assert planted_inline, (
"the member planted in an INLINE request body was NOT surfaced — `_inline_body_members` is "
"not reaching inline bodies, so a body declared without a $ref is invisible to this guard"
)
assert "PlantedDroppableRequest" in droppable, (
"the planted schema was NOT surfaced — the derivation cannot see a droppable member, so a "
"green run of this file proves nothing"
)
assert droppable["PlantedDroppableRequest"] == ["plantedOptional"]
assert "PlantedDroppableRequest" not in DISPOSITIONS
+8 -164
View File
@@ -50,11 +50,6 @@ if "-d" in args:
payload = args[args.index("-d") + 1]
if is_post:
# A POST can be scripted to fail (`post_fail` holds a URL substring), so the two write paths
# can be broken independently — the shape ersatztv#792 is about.
fail_on = (state / "post_fail").read_text().strip() if (state / "post_fail").exists() else ""
if fail_on and fail_on in url:
sys.exit(22)
with (state / "posts.jsonl").open("a") as fh:
fh.write(json.dumps({"url": url, "payload": json.loads(payload)}) + "\n")
print("{}")
@@ -78,10 +73,6 @@ if "/pulls/" in url and not url.endswith("/files"):
"state": (state / "pr_state").read_text().strip(),
"html_url": "http://gitea.example/timothy/ersatztv/pulls/42",
}
# A 2xx body that merely LOST the field, as distinct from an unreachable PR ('GONE' above).
# This is the shape the `[ -n "$x" ] &&` conjunct used to wave through (ersatztv#778).
if sha == "NOHEAD":
body["head"] = {}
if base != "MISSING":
body["base"] = {"ref": base}
print(json.dumps(body))
@@ -109,7 +100,7 @@ def gitea(tmp_path):
env = dict(os.environ)
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["ETV_GITEA_TOKEN"] = "stub-token" # noqa: S105 - deliberately fake; the real credential comes from the environment
env["ETV_GITEA_TOKEN"] = "stub-token"
env["ETV_GITEA_URL"] = "http://gitea.example"
env["ETV_GITEA_REPO"] = "timothy/ersatztv"
env.pop("ETV_GITEA_BASICAUTH", None)
@@ -125,10 +116,6 @@ def gitea(tmp_path):
def set_pr_state(self, value):
(state / "pr_state").write_text(value)
def fail_posts_to(self, url_substring):
"""Make POSTs whose URL contains this substring fail, as curl -f does on a 4xx/5xx."""
(state / "post_fail").write_text(url_substring)
def set_base_sequence(self, *refs):
"""Base branch per PR GET. 'MISSING' omits `.base` from the response entirely."""
(state / "pr_bases").write_text(" ".join(refs))
@@ -136,9 +123,7 @@ def gitea(tmp_path):
def run(self, *args):
return subprocess.run(
["bash", str(SCRIPT), *args],
env=env,
capture_output=True,
text=True,
env=env, capture_output=True, text=True,
)
def posts(self):
@@ -186,10 +171,8 @@ def test_refuses_when_head_moves_mid_flight(gitea):
assert result.returncode != 0
assert "UNREVIEWED" in result.stderr
assert gitea.statuses() == [], "no status may be written once the reviewed head is stale"
# And no comment either, since ersatztv#792. This assertion used to say the opposite — the
# comment went first, so a refusal left `Review-verdict: MERGEABLE @ <sha>` on the PR with no
# status behind it, which reads to an operator as consent that was never granted.
assert gitea.comments() == [], "a refusal must leave no verdict comment standing in for a status"
# The comment was already posted and honestly names the sha that WAS reviewed.
assert SHA_A[:7] in gitea.comments()[0]["payload"]["body"]
def test_never_retargets_the_verdict_at_the_new_head(gitea):
@@ -242,7 +225,6 @@ def test_unreachable_pr_is_an_error_not_a_silent_success(gitea):
# --- Cross-checks against the hook's own condition-(c) parser -----------------------------------
def _classify(body: str, head: str) -> str:
"""Run the REAL H10 classifier over a comment body — no Python mirror of the grammar.
@@ -253,7 +235,9 @@ def _classify(body: str, head: str) -> str:
passing here while the shell drifted.
"""
payload = json.dumps([{"body": body}])
p = subprocess.run(["bash", str(CLASSIFIER), "--head", head], input=payload, capture_output=True, text=True)
p = subprocess.run(
["bash", str(CLASSIFIER), "--head", head], input=payload, capture_output=True, text=True
)
assert p.returncode == 0, f"classifier errored: {p.stderr}"
return p.stdout.strip()
@@ -292,7 +276,6 @@ def test_note_cannot_forge_a_second_verdict_line(gitea):
# mirror case: the head sha and the status both hold still while the effective DIFF changes, so the
# verdict keeps reading green for a review nobody performed against that base.
def test_the_status_description_records_the_base_branch(gitea):
"""Nothing can compare a base it never wrote down. This field is what the hook reads back."""
assert gitea.run("42", "MERGEABLE").returncode == 0
@@ -346,8 +329,7 @@ def test_a_failed_HEAD_RECHECK_writes_no_status(gitea):
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
assert gitea.statuses() == [], (
"a status was written even though the head/base re-read failed — nothing was confirmed"
)
"a status was written even though the head/base re-read failed — nothing was confirmed")
# --- write-side polarity, the half the #774 rescue initially missed ------------------------------
@@ -392,141 +374,3 @@ def test_each_verdict_word_posts_its_established_polarity(word, expected, gitea)
"matching arm, so a token that has appeared in the other arm resolves there silently — and "
"a BLOCKED verdict posting `success` writes a green required context."
)
@pytest.mark.parametrize(
("head_seq", "base_seq", "field"),
[
(("NOHEAD",), ("main", "main"), "head sha"),
((SHA_A,), ("main", "MISSING"), "base ref"),
],
)
def test_a_reread_that_LOSES_a_field_refuses_instead_of_posting(head_seq, base_seq, field, gitea):
"""The fail-OPEN one level below the TOCTOU guard, found by cold review (ersatztv#778).
Both re-read checks were written as `[ -n "$x" ] && [ "$x" != "$want" ]`. That conjunct makes an
EMPTY value a no-op: a well-formed 2xx response that merely omits `.head.sha` or `.base.ref`
yields an empty variable, neither comparison runs, and the status is posted having confirmed
NOTHING about the head or the base while the script's whole purpose at that point is to refuse
unless it can confirm. The transport failure one line above was already fatal, which is exactly
what made this shape easy to miss: the loud case was handled and the quiet one was not.
Asserted on the OBSERVABLE outcome no status written rather than on message text, so it
still holds if the wording changes.
"""
gitea.set_head_sequence(SHA_A, *head_seq)
gitea.set_base_sequence(*base_seq)
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0, (
f"a re-read missing its {field} was accepted; the script posted a verdict having confirmed nothing about it"
)
assert not gitea.statuses(), (
f"a status was written despite the re-read carrying no {field} — this is the fail-open the -n conjunct created"
)
# --- ersatztv#792: no path may write no status and report success ------------------------------
#
# The issue was filed on an observed "printed the refusal AND exited 0". Re-measured on the tree
# that fixed the re-read fence: every refusal already exits NON-ZERO, and the exit-0 came from the
# caller's pipeline, not from the script. That is worth an executed contract rather than a second
# reading of the source — `die` is one line away from being edited into a `return`, and this file is
# where that would be caught. The parametrisation covers each refusal REASON, not one
# representative, because those paths were added at four different times and only the shared helper
# makes them agree today.
#
# SCOPE, since "every path" would overclaim: these are the eight refusal MODES reachable through the
# real entry point. The source also exits non-zero for a usage error (2), a failing `jq` (its own
# status, under `set -e` + `pipefail`), and a signal (128+n); none of those is a refusal DECISION,
# and only the non-zero-ness is common to all of them.
def _drive(gitea, mode):
if mode == "head-moved":
gitea.set_head_sequence(SHA_A, SHA_B)
elif mode == "reread-failed":
gitea.set_head_sequence(SHA_A, "GONE")
elif mode == "reread-lost-head":
gitea.set_head_sequence(SHA_A, "NOHEAD")
elif mode == "base-retargeted":
gitea.set_base_sequence("main", "some-feature-branch")
elif mode == "reread-lost-base":
gitea.set_base_sequence("main", "MISSING")
elif mode == "first-read-failed":
gitea.set_head_sequence("GONE")
elif mode == "pr-closed":
gitea.set_pr_state("closed")
elif mode == "status-post-failed":
gitea.fail_posts_to("/statuses/")
else: # pragma: no cover - a typo in the parametrisation must not pass silently
raise AssertionError(f"unknown mode {mode}")
return gitea.run("42", "MERGEABLE")
@pytest.mark.parametrize(
"mode",
[
"head-moved",
"reread-failed",
"reread-lost-head",
"base-retargeted",
"reread-lost-base",
"first-read-failed",
"pr-closed",
"status-post-failed",
],
)
def test_every_REFUSAL_MODE_that_writes_no_status_exits_non_zero(gitea, mode):
result = _drive(gitea, mode)
assert gitea.statuses() == [], f"{mode} wrote a status it had no business writing"
assert result.returncode != 0, (
f"{mode} wrote no status and reported SUCCESS — anything checking $? concludes the verdict "
f"posted. stdout={result.stdout!r} stderr={result.stderr!r}"
)
@pytest.mark.parametrize(
"mode",
[
"head-moved",
"reread-failed",
"reread-lost-head",
"base-retargeted",
"reread-lost-base",
"status-post-failed",
],
)
def test_no_refusal_leaves_a_VERDICT_COMMENT_standing_in_for_the_status(gitea, mode):
"""The half-state, which is the part of #792 that was really broken.
The comment is not the gate `review-verdict/h10` is but `Review-verdict: MERGEABLE @ <head>`
sitting on a PR reads exactly like consent. Every mode here is one where the status is refused
after the head has been resolved, i.e. every mode that could once have left that comment behind.
"""
_drive(gitea, mode)
assert gitea.comments() == [], f"{mode} left an orphaned verdict comment: {gitea.comments()}"
def test_the_status_is_written_BEFORE_the_comment(gitea):
"""Ordering is the mechanism, so it is asserted rather than described.
Status-then-comment makes the only reachable half-state the safe one: a status with no comment
leaves the merge hook's condition (c) with nothing to classify, which is an `ask`. The reverse
order manufactures the appearance of a granted verdict.
"""
assert gitea.run("42", "MERGEABLE").returncode == 0
urls = [p["url"] for p in gitea.posts()]
assert len(urls) == 2, urls
assert "/statuses/" in urls[0], f"the status must be written first, got {urls}"
assert "/comments" in urls[1], f"the comment must be written second, got {urls}"
def test_a_failed_COMMENT_after_a_written_status_is_still_an_error(gitea):
"""The surviving half-state is safe, not silent: the operator is told to re-run."""
gitea.fail_posts_to("/comments")
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
assert len(gitea.statuses()) == 1, "the status was already written and must not be rolled back"
assert gitea.comments() == []
assert "COMMENT could not be posted" in result.stderr
assert "ask" in result.stderr.lower(), "it must say what the gate will do, not just that a call failed"
File diff suppressed because it is too large Load Diff
@@ -1,330 +0,0 @@
"""`.husky/pre-push` line 11 — `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE` — is load-bearing.
That one line is the fix for a real, silent defect, and ersatztv#785 ranks it third because nothing
pinned it: reordering it after the nested git calls, or dropping it in a tidy-up, reintroduces the
bug with no symptom at all. The failure is a check reporting SUCCESS, which is the family
`testing.guard-ships-with-mutation-proof` exists for.
**The mechanism, measured rather than asserted.** Measured on both platforms this suite runs on,
because the guard it replaces was fail-open for months on exactly the platform nobody measured:
macOS/git 2.55 (development) and Linux/git 2.47.3 (the `script-tests` runner host). Identical on
both `GIT_DIR` exported, `show-toplevel` answering `<wt>/web`, the nested diff reporting exit 0.
The whole file was run green there, and the deletion mutation was witnessed red there:
* Git exports `GIT_DIR` to `pre-push` **when the push comes from a worktree** e.g.
`GIT_DIR=/repo/.git/worktrees/wt`. From the main tree it exports nothing, which is why this
never bites in a plain checkout and why it bites here constantly: `process.shared-tree-readonly`
makes working in a worktree the mandated path, so the exported-`GIT_DIR` case is the NORMAL one.
* With `GIT_DIR` set and `GIT_WORK_TREE` unset, git stops discovering the repo and takes the
**current directory** as the work tree. `git rev-parse --show-toplevel` from `web/` answers
`/repo/wt/web`.
* So `pre-push`'s last line — `cd web && npm run check:api`, whose `check:api` ends in
`git diff --exit-code` compares against index paths that do not exist under that root. It
reports **no diff and exits 0**. Generated-API drift ships, and the gate that exists to catch it
prints success.
The test drives the REAL `.husky/pre-push` file, unedited, in a real worktree, with the environment
git really exports. What is substituted is only what surrounds it: the three `.claude/hooks` calls
are stubs (they are separately guarded and are not the subject), and `npm` is a stub on PATH whose
`run check:api` performs the nested `git diff --exit-code` that the real one ends in. The subject
the ordering of the `unset` against the nested git call is untouched.
"""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
PRE_PUSH = REPO_ROOT / ".husky" / "pre-push"
UNSET_CLAUSE = "unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE"
# The hooks `pre-push` calls before the CI-parity block. Stubbed because each is guarded on its own
# terms and none of them is what this file is about — but DERIVED from the real file rather than
# hand-listed, so a hook added to `pre-push` tomorrow cannot leave this harness silently running a
# `pre-push` that dies at a missing script and calling that a red.
def _hook_calls(text: str) -> list[str]:
"""Every `.claude/hooks/` script `pre-push` actually invokes.
Two refinements over a plain substring scan, each closing a way the harness would misreport:
* **comment lines are skipped.** A comment naming a hook that no longer exists would otherwise
redden `test_the_stub_hooks_are_derived_from_the_real_file` for a file that is perfectly
correct.
* **the prefix is not assumed to be `./`.** A hook invoked as `bash .claude/hooks/x.sh` would
be missed, left unstubbed, and exit 127 a NON-ZERO status that two tests here read as
"drift was caught". That is a false green in the direction that matters, so the match is on
the path segment rather than on `./`.
"""
names = []
for line in text.splitlines():
# INLINE comments too, not just whole-line ones. Broadening the marker from `./.claude/hooks/`
# to the path segment made a trailing `# ... .claude/hooks/removed-helper.sh` match, which
# would redden this file for a `pre-push` that is perfectly correct — a false red the
# narrower marker did not have. The fix for one over-match must not introduce another.
stripped = line.split("#", 1)[0].strip()
marker = ".claude/hooks/"
if marker in stripped:
rest = stripped.split(marker, 1)[1]
names.append(rest.split()[0].rstrip("|&;\"'"))
return names
def _git(cwd: Path, *args: str) -> str:
return subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
text=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@e",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@e",
},
).stdout.strip()
def _exe(path: Path, body: str) -> None:
path.write_text(body)
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
class Bench:
"""A real repo, a real worktree, the real `pre-push`, and the env git really exports."""
def __init__(self, tmp_path: Path, pre_push_text: str, *, drift: bool):
tmp_path.mkdir(parents=True, exist_ok=True)
self.root = tmp_path / "main-tree"
self.root.mkdir()
_git(self.root, "init", "-q", "-b", "main", ".")
(self.root / "seed").write_text("seed\n")
_git(self.root, "add", "seed")
_git(self.root, "commit", "-qm", "init")
self.wt = tmp_path / "wt"
_git(self.root, "worktree", "add", "-q", str(self.wt), "-b", "feature")
web = self.wt / "web"
web.mkdir()
(web / "gen.txt").write_text("generated\n")
_git(self.wt, "add", "web/gen.txt")
_git(self.wt, "commit", "-qm", "add generated file")
if drift:
# The condition `check:api` exists to catch: the committed generated artifact no longer
# matches what regeneration produces.
(web / "gen.txt").write_text("generated\nDRIFT\n")
(self.wt / ".husky").mkdir()
self.pre_push = self.wt / ".husky" / "pre-push"
_exe(self.pre_push, pre_push_text)
hooks = self.wt / ".claude" / "hooks"
hooks.mkdir(parents=True)
for name in _hook_calls(pre_push_text):
_exe(hooks / name, "#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n")
# The npm stand-in. `run check:api` performs the nested `git diff --exit-code` the real
# script ends in; everything else is a no-op. It records the nested diff's OWN exit code,
# not merely that it ran. That distinction is load-bearing: one of the mutations below moves
# the `unset` to the end of the file, which also makes the script's terminal status 0 (a
# bare `unset` succeeds) — so asserting on pre-push's exit code alone would be satisfied for
# a reason that has nothing to do with the nested git call. The recorded diff verdict IS the
# defect; the script's exit code is downstream of it.
self.witness = tmp_path / "check-api-ran"
bindir = tmp_path / "bin"
bindir.mkdir()
_exe(
bindir / "npm",
"#!/usr/bin/env bash\n"
'if [ "$1" = "run" ] && [ "$2" = "check:api" ]; then\n'
" git diff --exit-code -- gen.txt >/dev/null\n"
" _rc=$?\n"
f' echo "$PWD $_rc" >> "{self.witness}"\n'
" exit $_rc\n"
"fi\n"
"exit 0\n",
)
self.bindir = bindir
# `GIT_DIR` exactly as git exports it for a push from this worktree, verified against a real
# push in the investigation that produced this file.
self.git_dir = _git(self.wt, "rev-parse", "--absolute-git-dir")
def run(self, *, export_git_dir: bool) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
env["PATH"] = f"{self.bindir}:{env['PATH']}"
if export_git_dir:
env["GIT_DIR"] = self.git_dir
return subprocess.run(
["bash", str(self.pre_push), "origin", "file:///dev/null"],
input=b"refs/heads/feature abc refs/heads/feature def\n",
capture_output=True,
cwd=str(self.wt),
env=env,
timeout=120,
)
def check_api_ran(self) -> bool:
return self.witness.exists()
def nested_diff_rc(self) -> int:
"""The exit code the nested `git diff --exit-code` actually reported.
0 means it saw NO diff. With a drifted tree that answer is the bug.
"""
assert self.witness.exists(), "check:api never ran, so there is no nested diff verdict"
return int(self.witness.read_text().split()[-1])
# ------------------------------------------------------------------------------------------------
# ANTI-VACUITY — the clause exists, and the harness reaches the check that depends on it
# ------------------------------------------------------------------------------------------------
def test_the_unset_clause_is_still_in_pre_push():
text = PRE_PUSH.read_text()
assert UNSET_CLAUSE in text, (
f"`{UNSET_CLAUSE}` is gone from .husky/pre-push. If it was removed deliberately, this file "
"must be removed with it and docs/guard-inventory.md updated — do not delete this assertion "
"on its own, it is the only thing pinning the ordering"
)
lines = text.splitlines()
unset_at = next(i for i, ln in enumerate(lines) if UNSET_CLAUSE in ln)
nested_at = next(i for i, ln in enumerate(lines) if "npm run check:api" in ln)
assert unset_at < nested_at, (
"the unset now comes AFTER the nested git call it exists to protect — that ordering is the "
"regression, and it is silent"
)
def test_the_harness_actually_reaches_check_api(tmp_path):
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=True)
bench.run(export_git_dir=False)
assert bench.check_api_ran(), (
"the CI-parity block never ran, so every exit code below would be reporting on the hooks "
"before it rather than on the nested git call this file is about"
)
# ------------------------------------------------------------------------------------------------
# THE GUARD DECIDES
# ------------------------------------------------------------------------------------------------
def test_drift_is_CAUGHT_with_no_git_env_exported(tmp_path):
"""Positive control: the harness detects real drift when nothing is in the way."""
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=True)
p = bench.run(export_git_dir=False)
assert bench.nested_diff_rc() != 0, "the nested diff saw no drift even with a clean env"
assert p.returncode != 0, f"the harness did not detect drift even with a clean env: {p.stderr!r}"
def test_drift_is_CAUGHT_when_git_exports_GIT_DIR_from_a_worktree(tmp_path):
"""THE REAL CASE. Every push from a worktree — the mandated way to work here — runs this."""
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=True)
p = bench.run(export_git_dir=True)
assert bench.nested_diff_rc() != 0, (
"the nested `git diff --exit-code` reported NO DIFF on a drifted file. That is the silent "
"fail-open the `unset` exists to prevent, and it is invisible from the exit code alone"
)
assert p.returncode != 0, (
"pre-push reported success on a drifted generated file while git had exported GIT_DIR: "
f"{p.stdout!r} {p.stderr!r}"
)
def test_a_CLEAN_tree_is_allowed_through(tmp_path):
"""Negative control. A harness that always failed would pass both assertions above."""
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=False)
p = bench.run(export_git_dir=True)
assert p.returncode == 0, f"a clean tree was blocked: {p.stdout!r} {p.stderr!r}"
assert bench.nested_diff_rc() == 0, "the nested diff invented a diff on a clean tree"
# ------------------------------------------------------------------------------------------------
# MUTATION PROOFS — the two ways the clause stops protecting anything
# ------------------------------------------------------------------------------------------------
def _positive_control(tmp_path: Path, label: str) -> None:
bench = Bench(tmp_path / f"pc-{label}", PRE_PUSH.read_text(), drift=True)
p = bench.run(export_git_dir=True)
assert bench.nested_diff_rc() != 0 and p.returncode != 0, (
"the UNMUTATED pre-push did not catch the drift, so 'the mutant lets it through' proves "
f"nothing about the clause: {p.stdout!r} {p.stderr!r}"
)
def test_MUTATION_DELETING_the_unset_lets_drift_through_silently(tmp_path):
_positive_control(tmp_path, "delete")
text = PRE_PUSH.read_text()
assert UNSET_CLAUSE in text, "retarget this mutation; the clause has moved"
mutated = text.replace(UNSET_CLAUSE, "# clause removed by the mutation proof", 1)
bench = Bench(tmp_path / "mut", mutated, drift=True)
p = bench.run(export_git_dir=True)
assert bench.check_api_ran(), "the mutant died before check:api, so its exit code says nothing"
assert bench.nested_diff_rc() == 0, (
"removing the unset did NOT blind the nested diff, so the clause is not what protects it "
"and this whole file is pinning the wrong thing"
)
assert p.returncode == 0, f"the drift was still caught somehow: {p.stdout!r}"
def test_MUTATION_REORDERING_the_unset_after_the_nested_git_call_lets_drift_through(tmp_path):
"""The regression ersatztv#785 names by hand: not deletion, relocation.
Deleting a line is a conspicuous diff. Moving it during a tidy-up, or when a new check is
appended above it reads as a no-op and is not.
This asserts on the NESTED DIFF's verdict, not on pre-push's exit code. Relocating the `unset`
to the end of the file also makes it the script's last statement, and a bare `unset` succeeds —
so `returncode == 0` would hold here even if the nested git call had worked perfectly. That is a
test passing for the wrong reason, and it was written that way in this file's first draft.
"""
_positive_control(tmp_path, "reorder")
lines = PRE_PUSH.read_text().splitlines()
kept = [ln for ln in lines if UNSET_CLAUSE not in ln]
assert len(kept) == len(lines) - 1, "expected exactly one unset line to relocate"
mutated = "\n".join(kept + [UNSET_CLAUSE, ""])
bench = Bench(tmp_path / "mut", mutated, drift=True)
bench.run(export_git_dir=True)
assert bench.check_api_ran(), "the mutant died before check:api, so it reports on nothing"
assert bench.nested_diff_rc() == 0, (
"moving the unset below the nested git call did NOT blind it, so the ORDERING is not "
"load-bearing and the ordering assertion in the anti-vacuity test is decoration"
)
def test_the_stub_hooks_are_derived_from_the_real_file():
"""If `pre-push` gains a hook call, the harness must stub it rather than die at a missing file.
A `pre-push` that exits 127 at a missing script produces a non-zero exit indistinguishable
from 'drift was caught' in two of the tests above.
"""
calls = _hook_calls(PRE_PUSH.read_text())
assert calls, "no ./.claude/hooks/ call found in pre-push; the extractor has stopped matching"
for name in calls:
assert (REPO_ROOT / ".claude" / "hooks" / name).is_file(), (
f"pre-push calls {name}, which does not exist in .claude/hooks/"
)
def test_shutil_which_npm_is_not_what_the_harness_used(tmp_path):
"""Anti-vacuity for the stand-in: a real `npm` on PATH would run the real scripts and pass."""
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=True)
resolved = shutil.which("npm", path=f"{bench.bindir}:{os.environ['PATH']}")
assert resolved == str(bench.bindir / "npm"), (
f"the harness would have used {resolved}, not its stand-in — the check it performs would "
"then be whatever the real package.json says, not the nested git call under test"
)
+27 -46
View File
@@ -35,18 +35,14 @@ PROVE_FIX = Path(os.environ.get("PROVE_FIX_PATH") or (REPO_ROOT / "scripts" / "p
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True,
capture_output=True,
text=True,
check=True, capture_output=True, text=True,
).stdout.strip()
def _run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(PROVE_FIX), "--repo", str(repo), *args],
capture_output=True,
text=True,
cwd=str(repo),
capture_output=True, text=True, cwd=str(repo),
)
@@ -65,7 +61,9 @@ def fixrepo(tmp_path: Path) -> Path:
# --- commit 1: the bug, plus a test that cannot see it
(repo / "calc.py").write_text("def add(a, b):\n return a - b # bug\n")
(repo / "scripts" / "tests" / "test_unrelated.py").write_text("def test_unrelated():\n assert 1 + 1 == 2\n")
(repo / "scripts" / "tests" / "test_unrelated.py").write_text(
"def test_unrelated():\n assert 1 + 1 == 2\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial: buggy add, unrelated test")
@@ -75,7 +73,8 @@ def fixrepo(tmp_path: Path) -> Path:
"from calc import add\n\n\ndef test_add():\n assert add(2, 3) == 5\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "fix: add() returned a difference\n\nProves: scripts/tests/test_add.py")
_git(repo, "commit", "-q", "-m",
"fix: add() returned a difference\n\nProves: scripts/tests/test_add.py")
return repo
@@ -144,8 +143,7 @@ def test_added_code_file_is_removed_not_checked_out(tmp_path: Path) -> None:
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "scripts" / "tests" / "keep.py").write_text("# placeholder\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial")
_git(repo, "add", "-A"); _git(repo, "commit", "-q", "-m", "initial")
# the fix ADDS helper.py (it has no parent version) and a test that needs it
(repo / "helper.py").write_text("def shout(s):\n return s.upper()\n")
@@ -167,8 +165,7 @@ def test_root_commit_REFUSES(tmp_path: Path) -> None:
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "a.py").write_text("x = 1\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "root")
_git(repo, "add", "-A"); _git(repo, "commit", "-q", "-m", "root")
r = _run(repo, "HEAD", "scripts/tests")
assert r.returncode == 5
assert "root commit" in r.stderr
@@ -188,10 +185,9 @@ def test_runs_under_the_system_bash(fixrepo: Path) -> None:
# developer Mac. The 3.2 hazard (mapfile yielding an empty array) is what motivated it.
ver = subprocess.run([str(system_bash), "--version"], capture_output=True, text=True).stdout
r = subprocess.run(
[str(system_bash), str(PROVE_FIX), "--repo", str(fixrepo), "HEAD", "scripts/tests/test_add.py"],
capture_output=True,
text=True,
cwd=str(fixrepo),
[str(system_bash), str(PROVE_FIX), "--repo", str(fixrepo), "HEAD",
"scripts/tests/test_add.py"],
capture_output=True, text=True, cwd=str(fixrepo),
)
assert r.returncode == 0, (
f"prove-fix.sh must work under the system bash ({ver.splitlines()[0] if ver else '?'}); "
@@ -208,7 +204,8 @@ def test_control_failure_REFUSES(fixrepo: Path) -> None:
)
(fixrepo / "calc.py").write_text("def add(a, b):\n return a + b # unchanged\n")
_git(fixrepo, "add", "-A")
_git(fixrepo, "commit", "-q", "-m", "fix: with an already-failing test\n\nProves: scripts/tests/test_broken.py")
_git(fixrepo, "commit", "-q", "-m",
"fix: with an already-failing test\n\nProves: scripts/tests/test_broken.py")
r = _run(fixrepo, "HEAD")
assert r.returncode == 6, f"expected 6 (control failed), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "control FAILED" in r.stderr
@@ -251,19 +248,9 @@ def test_MUTATION_disarming_the_UNPROVEN_clause_reddens_the_refusal_test(
env = {**os.environ, "PROVE_FIX_PATH": str(mutant), "PROVE_FIX_MUTATION_RUN": "1"}
nested = subprocess.run(
[
"python3",
"-m",
"pytest",
f"{Path(__file__).name}::test_unrelated_test_is_UNPROVEN",
"-q",
"-p",
"no:cacheprovider",
],
cwd=str(Path(__file__).parent),
env=env,
capture_output=True,
text=True,
["python3", "-m", "pytest", f"{Path(__file__).name}::test_unrelated_test_is_UNPROVEN",
"-q", "-p", "no:cacheprovider"],
cwd=str(Path(__file__).parent), env=env, capture_output=True, text=True,
)
out = nested.stdout + nested.stderr
assert nested.returncode == 1, (
@@ -295,20 +282,17 @@ def test_SIGTERM_mid_run_never_reports_PROVEN(tmp_path: Path) -> None:
_git(repo, "config", "user.name", "T")
(repo / "mod.py").write_text("VALUE = 1\n")
(repo / "scripts" / "tests" / "test_slow.py").write_text(
"import time\nfrom mod import VALUE\n\n\ndef test_slow():\n time.sleep(20)\n assert VALUE == 2\n"
"import time\nfrom mod import VALUE\n\n\n"
"def test_slow():\n time.sleep(20)\n assert VALUE == 2\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial")
_git(repo, "add", "-A"); _git(repo, "commit", "-q", "-m", "initial")
(repo / "mod.py").write_text("VALUE = 2\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "fix: bump\n\nProves: scripts/tests/test_slow.py")
proc = subprocess.Popen(
["bash", str(PROVE_FIX), "--repo", str(repo), "HEAD"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=str(repo),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=str(repo),
)
time.sleep(4) # inside the control run, which sleeps 20s
assert proc.poll() is None, "the run finished before it could be signalled; test is void"
@@ -321,7 +305,9 @@ def test_SIGTERM_mid_run_never_reports_PROVEN(tmp_path: Path) -> None:
# bugs landing on the same observable is not a witnessed fix. Cold review measured that
# pair failing to separate old from new; rc==5 AND the handler's own message do separate
# them.
assert proc.returncode == 5, f"a signalled run must exit 5 from on_signal, got {proc.returncode}\n{out}\n{err}"
assert proc.returncode == 5, (
f"a signalled run must exit 5 from on_signal, got {proc.returncode}\n{out}\n{err}"
)
assert "interrupted by signal" in err, (
f"expected the signal handler's own message, so this test cannot be satisfied by an "
f"unrelated later failure:\n{err}"
@@ -349,8 +335,7 @@ def test_a_harness_failure_is_NOT_reported_as_PROVEN(tmp_path: Path) -> None:
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "scripts" / "tests" / "keep.py").write_text("# placeholder\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial")
_git(repo, "add", "-A"); _git(repo, "commit", "-q", "-m", "initial")
(repo / "added.py").write_text("def val():\n return 7\n")
(repo / "scripts" / "tests" / "test_added.py").write_text(
"from added import val\n\n\ndef test_val():\n assert val() == 7\n"
@@ -358,8 +343,7 @@ def test_a_harness_failure_is_NOT_reported_as_PROVEN(tmp_path: Path) -> None:
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "feat: add val\n\nProves: scripts/tests/test_added.py")
shim = tmp_path / "bin"
shim.mkdir()
shim = tmp_path / "bin"; shim.mkdir()
counter = tmp_path / "count"
(shim / "git").write_text(
"#!/bin/sh\n"
@@ -374,10 +358,7 @@ def test_a_harness_failure_is_NOT_reported_as_PROVEN(tmp_path: Path) -> None:
env = {**os.environ, "PATH": f"{shim}:{os.environ['PATH']}"}
r = subprocess.run(
["bash", str(PROVE_FIX), "--repo", str(repo), "HEAD"],
capture_output=True,
text=True,
cwd=str(repo),
env=env,
capture_output=True, text=True, cwd=str(repo), env=env,
)
assert "PROVEN" not in r.stdout, (
"a phase whose worktree does not exist cannot witness anything; pre-fix this "
@@ -1,328 +0,0 @@
"""`docs/remote-state-inventory.md` covers exactly the in-scope files, each classified for whether
it reads live remote state and acts on that read (ersatztv#778).
WHAT THIS CANNOT DO, said first because #778's own issue body says it: there is no lint for "this
code should have pinned a sha." `docs/defect-shapes-773.md` §4 grades detector D as a *fix pattern*
whose detector is detector A applied to an enumerated inventory. So this file does not try to grade
pinning. It guarantees that every in-scope file has been CLASSIFIED BY SOMEONE, and that no file
joined the scope without acquiring a row which converts "remember to think about this" into "the
suite is red until you have".
The split is deliberate and mirrors `test_guard_inventory.py`:
* the POPULATION is derived from `git ls-files` and compared for SET EQUALITY, both directions;
* the CLASSIFICATION vocabulary is closed, so a typo cannot invent a state;
* whether a `PINNED` row is TELLING THE TRUTH is not checked here and cannot be. That stays with
review, and the inventory's prose is what review reads.
SCOPE vs POPULATION, per `testing.guard-derives-population-from-source`: the SCOPE four
directories, their per-directory file patterns, and one excluded subdirectory is a hand-written
policy choice and is reviewable as one. The POPULATION inside that scope is derived on every run
from the git index, with no content predicate at all: a file that reads no remote state earns an
explicit `N/A` row rather than staying out, which is why most rows are `N/A`.
No count is written here on purpose. An exact "N of M" has now gone stale FOUR times in this change,
most recently in the same commit that demoted two rows a hand-maintained number is a second copy of
the table, and `docs/guard-inventory.md` earns its counts by having a test assert them. Nothing
asserts one here, so nothing states one.
That is the fourth version, and the history is the point see the `SCOPE` comment below for the
three that failed and what each one hid.
"""
from __future__ import annotations
import fnmatch
import re
from pathlib import Path
from scripts.tests import tracked_files
REPO_ROOT = Path(__file__).resolve().parents[2]
INVENTORY = REPO_ROOT / "docs" / "remote-state-inventory.md"
CLASSES = {"PINNED", "CAS", "UNSAFE-KNOWN", "N/A"}
# NO CONTENT FILTER. The population is every file in the scoped directories, and a file that reads
# no remote state earns an `N/A` row rather than silently staying out.
#
# The first version filtered on a token list (`curl`, `wget`, `urllib`, ...) and called that a
# SCOPE choice rather than a population filter. Cold review rejected the distinction and was right:
# the list omitted `git fetch`, which is this repo's most common remote read, so
# `.claude/hooks/prepush-rebase-check.sh` — which fetches `origin/main` and derives a PUSH DECISION
# from it — was structurally invisible to a guard whose stated claim is "every executable that
# reads live remote state". Three more (`prepush-clean-worktree-check.sh`, `ci-detect-docs-only.sh`,
# `refresh-shared-checkout.sh`) were missing for the same reason.
#
# That is precisely the defect `testing.guard-derives-population-from-source` describes: a filter
# cannot see the member that is missing, because the absent member is not a row the predicate
# rejected, it is a row that was never produced. The defence offered for it — "over-inclusion is the
# safe direction" — was answered by the filter ALSO under-including. Enumerating the directories
# costs more rows and has no blind spot; that is the trade the rule already made.
SCOPE = (
("scripts", ("*.sh", "*.py")),
(".claude/hooks", ("*.sh",)),
(".husky", ("*",)),
(".gitea/workflows", ("*.yml", "*.yaml")),
)
# THE POPULATION IS DERIVED FROM `git ls-files`, NOT FROM THE FILESYSTEM.
#
# This is the third time this population has been found incomplete or wrong, each time by a
# different mechanism, and the third fix is the one that stops patching the traversal:
#
# 1. a content filter on an outbound-network token list, which omitted `git fetch` — this repo's
# commonest remote read — so a hook that fetches `origin/main` and derives a PUSH DECISION was
# structurally invisible;
# 2. a non-recursive `Path.glob`, which missed four nested files including one that calls a live
# ErsatzTV API and acts on the reply;
# 3. `Path.rglob`, which is recursive and therefore ALSO enumerated `.husky/_/` — 17 husky shims
# generated by `npm ci` via web/package.json's `prepare` script, gitignored (`.husky/_/.gitignore`
# is `*`) and untracked. That made this guard RED on every developer checkout while staying green
# in CI, whose `script-tests` job checks out and pip-installs but never runs `npm ci`. A guard
# that fails everywhere except where it runs is worse than no guard: it trains its readers to
# ignore it, and it would have done so on the artifact whose entire thesis is population
# correctness.
#
# The lesson each time was the same one this repo already wrote down — derive the population from an
# AUTHORITATIVE source — and the filesystem is not one. It reports build output, editor droppings and
# anything else that happens to be on disk, and it varies per machine. The repo's index is
# authoritative: it holds the same set of files every checkout receives from a clone, and it
# excludes untracked generated files by construction rather than by an exclusion list that must be
# maintained.
#
# `scripts/tests/` is still excluded explicitly, because those files ARE tracked. That exclusion is a
# scope decision, reviewable in one line: they run only under pytest and authorize nothing. Their
# network activity is CONFINED rather than absent — `test_hook_fire_log.py` starts a real
# `http.server` on 127.0.0.1 and drives it with real `curl`, and several suites create real local git
# remotes — but all of it is fixture state the test creates and tears down, so there is no live remote
# to race.
EXCLUDED_DIRS = ("scripts/tests",)
# `| ` + backticked path, optionally followed by ` — <site description>`, then the class cell.
_ROW = re.compile(r"^\|\s*`([^`]+?)`[^|]*\|\s*`([^`]+)`\s*\|", re.M)
def _tracked_files() -> list[str]:
"""Every file git tracks, as repo-relative posix paths.
A thin wrapper over the SHARED derivation rather than a second copy of it (ersatztv#806): one
definition of the rule, not two, which is detector C dedup by construction applied to the
file that first stated the rule. The wrapper survives because this module patches
`_tracked_files` by name in its own proofs, and because its scope is RECURSIVE over `scripts/`
where `tracked_children` is deliberately flat.
It therefore carries BOTH its own proofs and a row in that file's `DERIVATIONS`, which is not
the duplication that masks: they cut at different seams the in-file pair patches
`_tracked_files` (this wrapper), the shared pair patches `_git_ls_files` (the subprocess) and
each was witnessed red independently, so neither can hide the other's total failure.
Fails LOUDLY rather than returning nothing, now including git's own stderr: an empty population
would make every completeness assertion below pass vacuously, which is the exact failure this
guard exists to prevent.
"""
return tracked_files._git_ls_files()
def derived_population() -> set[str]:
"""Every in-scope tracked file, as a repo-relative posix path.
No content predicate of any kind: a file that reads no remote state earns an explicit `N/A` row
rather than silently staying out.
"""
found: set[str] = set()
for path in _tracked_files():
if any(path == d or path.startswith(d + "/") for d in EXCLUDED_DIRS):
continue
for directory, globs in SCOPE:
if not (path == directory or path.startswith(directory + "/")):
continue
name = path.rsplit("/", 1)[-1]
if any(fnmatch.fnmatch(name, pattern) for pattern in globs):
found.add(path)
# First matching scope entry wins. Safe only while no scope directory nests inside
# another; if one ever does, the inner entry's patterns would be silently skipped.
break
return found
def _inventory_section(text: str) -> str:
"""Only the classification tables, never the surrounding prose.
This is the second time this parser has read the document's own explanation as data: the
UNSAFE-KNOWN justification check once parsed the "Columns" paragraph that DEFINES
`UNSAFE-KNOWN`, and adding a scope TABLE to the heading made three more prose rows parse as
sites. A guard that treats its own documentation as input is the failure this whole change is
about, so the boundary is explicit rather than left to a cleverer regex.
"""
for heading in ("## The inventory", "## Limits"):
if heading not in text:
raise AssertionError(
f"{INVENTORY.name} has no {heading!r} heading. Row parsing is bounded by "
"'## The inventory' and '## Limits'; renaming or reordering either one would "
"silently change which rows are checked, so it fails here instead."
)
start = text.index("## The inventory")
try:
end = text.index("## Limits", start)
except ValueError:
# Reachable when '## Limits' exists but PRECEDES '## The inventory' — the presence check
# above passes and the bounded search does not. An earlier version put an `end <= start`
# guard here instead, which `str.index(…, start)` makes unreachable by construction: it
# either returns an index >= start or raises. A guard that cannot execute proves nothing.
raise AssertionError(
f"{INVENTORY.name}: '## Limits' precedes '## The inventory', so the parsed window "
"would be empty and every completeness assertion would pass vacuously."
) from None
return text[start:end]
def inventory_rows(text: str | None = None) -> list[tuple[str, str]]:
if text is None:
text = INVENTORY.read_text(encoding="utf-8")
return _ROW.findall(_inventory_section(text))
def inventory_sites(text: str | None = None) -> set[str]:
return {site for site, _ in inventory_rows(text)}
def test_the_inventory_file_exists_and_is_not_empty():
assert INVENTORY.is_file(), f"{INVENTORY} is missing"
assert INVENTORY.stat().st_size > 0
def test_anti_vacuity_the_derivation_and_the_table_both_found_something():
"""The characteristic failure of a completeness check is reporting success over an empty
population. Both sides get a floor, because either one collapsing to zero would make the set
comparison below pass trivially."""
population = derived_population()
sites = inventory_sites()
assert len(population) >= 40, (
f"derived only {len(population)} in-scope files — the globs are broken, not the repo "
"(the scope held 59 files on 2026-08-16, and it only grows)"
)
assert len(sites) >= 40, (
f"parsed only {len(sites)} rows out of the inventory — the row regex has drifted from the table format"
)
def test_every_in_scope_file_has_a_row_and_every_row_names_a_real_file():
"""Set equality in BOTH directions, because the two failures are different defects and a single
'sets differ' message invites fixing one and re-running.
MISSING: a script that talks to a remote service and was never classified the defect #778
exists to prevent. PHANTOM: a row for a file that was renamed or deleted, which leaves the table
claiming coverage it has lost.
"""
population = derived_population()
sites = inventory_sites()
missing = sorted(population - sites)
phantom = sorted(sites - population)
assert not missing, (
"in scope but absent from docs/remote-state-inventory.md (classify each as "
f"PINNED / CAS / UNSAFE-KNOWN / N/A): {missing}"
)
assert not phantom, f"listed in docs/remote-state-inventory.md but no such in-scope file exists: {phantom}"
def test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected():
"""The proof that the set comparison above is load-bearing (`testing.guard-ships-with-mutation-
proof`). This guard IS a test, so disarming it makes it absent rather than red; the admissible
proof is therefore the contrapositive introduce the defect into an isolated copy of the
GUARDED ARTIFACT and show the comparison reports it.
Both directions are mutated, because they are different defects: a dropped row is an
unclassified script, a phantom row is a table claiming coverage it has lost. This ran for real
on the day it was written `dependency-scan.yml` was genuinely absent from the first draft of
the inventory and this comparison is what found it.
"""
text = INVENTORY.read_text(encoding="utf-8")
population = derived_population()
victim = sorted(population)[0]
dropped = "\n".join(line for line in text.splitlines() if not line.startswith(f"| `{victim}`"))
assert victim not in inventory_sites(dropped), (
f"the mutation did not actually remove {victim}; the proof below would be vacuous"
)
assert population - inventory_sites(dropped), (
"a row was removed from the inventory and the comparison still reported complete coverage"
)
# Inserted INSIDE the inventory section, not appended to the file: rows are parsed only between
# "## The inventory" and "## Limits", so appending at the end would test nothing.
phantom = text.replace("## Limits", "| `scripts/does-not-exist.sh` — invented | `PINNED` | n/a |\n\n## Limits", 1)
assert inventory_sites(phantom) - population == {"scripts/does-not-exist.sh"}, (
"a row naming a file that does not exist was not reported as phantom"
)
def test_every_class_cell_comes_from_the_closed_vocabulary():
bad = sorted({cls for _, cls in inventory_rows() if cls not in CLASSES})
assert not bad, (
f"unknown classification(s) {bad}; allowed: {sorted(CLASSES)}. A typo here would silently "
"create a state nobody reviews."
)
def test_every_unsafe_row_states_why_the_residual_is_accepted():
"""`UNSAFE-KNOWN` means 'accepted with a reason', not 'noticed'. A row that records the window
without the argument for tolerating it is how a deferral becomes permanent by default."""
text = _inventory_section(INVENTORY.read_text(encoding="utf-8"))
thin = []
for line in text.splitlines():
# TABLE ROWS ONLY. The first version matched any line containing the token, so the prose in
# "Columns" that DEFINES `UNSAFE-KNOWN` was parsed as a row and the split blew up. A guard
# that reads its own documentation as data is the failure this whole change is about.
if not line.startswith("|") or "`UNSAFE-KNOWN`" not in line:
continue
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) < 3:
continue
site, note = cells[0], cells[-1]
if len(note) < 120:
thin.append(site[:60])
assert not thin, f"UNSAFE-KNOWN row(s) with no stated justification: {thin}"
def test_the_population_never_includes_a_file_git_does_not_track(monkeypatch):
"""The regression for the third population defect, and the reason the source is the index.
`Path.rglob` enumerated `.husky/_/` 17 husky shims generated by `npm ci`, gitignored and
untracked so this guard was RED on every developer checkout and GREEN in CI, which never runs
`npm ci`. A guard that fails everywhere except where it runs trains its readers to ignore it.
Asserted through the mechanism rather than against the current disk, so it holds on a machine
that has never installed husky: the tracked list is narrowed, and anything outside it must
disappear from the population even though it is still sitting on disk and still matches the
scope globs.
"""
real = derived_population()
assert real, "empty population — the derivation is broken, not the repo"
victim = sorted(real)[0]
tracked = [p for p in _tracked_files() if p != victim]
monkeypatch.setattr(
"scripts.tests.test_remote_state_inventory._tracked_files",
lambda: tracked,
raising=False,
)
# Patch the module object this test is running inside, whatever name it was imported under.
import sys
mod = sys.modules[__name__]
monkeypatch.setattr(mod, "_tracked_files", lambda: tracked)
assert (REPO_ROOT / victim).is_file(), f"{victim} must still exist on disk for this proof to mean anything"
assert victim not in derived_population(), (
f"{victim} is on disk and matches the scope, but git no longer tracks it — it must not enter "
"the population, or untracked build output can redden this guard again"
)
def test_every_derived_member_is_tracked():
"""The same property stated as an invariant over the real tree, so a future refactor that goes
back to walking the filesystem fails here rather than on someone's laptop."""
tracked = set(_tracked_files())
stray = sorted(p for p in derived_population() if p not in tracked)
assert not stray, f"population contains untracked path(s): {stray}"
@@ -1,490 +0,0 @@
"""The worktree-ownership mechanism is TWO files, and this drives both halves as one thing.
`pretooluse-worktree-guard.sh` denies a `git commit`/`git merge` inside a worktree another session
created. It can only do that because `posttooluse-worktree-marker.sh` wrote the
`.claude-worktree-owner` marker at `git worktree add` time. Neither file had a test, and the part
that makes this rank second in ersatztv#785 — **the halves had never been exercised together**, so a
regression in either one is invisible: the marker hook silently writing nothing and the guard hook
silently reading nothing produce the identical outcome, which is *the commit is allowed*, which is
also what a correct fail-open looks like.
Both hooks are deliberately fail-open (`docs/decisions` the main tree is never marked, and
pre-convention worktrees have no marker), and that is exactly why an absent mechanism is
indistinguishable from a working one from the outside. It is the shape
`testing.guard-ships-with-mutation-proof` was written for: "in every case a human had read the guard
and believed it worked. The guard was not subtly wrong, it was *absent*."
So this file:
* drives the REAL pair end to end over a REAL `git worktree add`, in the real payload shape
marker hook first as the harness would fire it, then the guard hook;
* carries a negative control (a non-mutating git command) and a fail-open control (an unmarked
worktree), because a guard that denied everything would satisfy the deny assertions;
* and performs FOUR clause-level mutations: the guard's marker read, the guard's ownership
comparison, the command-detection alternation (`commit|merge`), and the *other file's* marker
write. The last is the one that could not exist while the halves were tested apart.
These four are the clauses whose disarm this file detects. They are not every line in either hook
the `git -C` / `cd` redirection extraction and the marker hook's argument parsing are exercised
behaviourally but not mutated, and the grade in `docs/guard-inventory.md` covers the clause its
cited case mutates, not the whole file.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
GUARD = REPO_ROOT / ".claude" / "hooks" / "pretooluse-worktree-guard.sh"
MARKER_HOOK = REPO_ROOT / ".claude" / "hooks" / "posttooluse-worktree-marker.sh"
MARKER_NAME = ".claude-worktree-owner"
SESSION_A = "session-aaaa-1111"
SESSION_B = "session-bbbb-2222"
def _env() -> dict:
"""The subprocess environment, built PER CALL — never snapshotted at import.
Two things it must get right.
`CLAUDE_PROJECT_DIR` is pinned because the hooks resolve `scripts/hook-fire-log.sh` from it,
falling back to a path relative to their own location; a MUTATED copy lives in tmp_path, where
that fallback finds nothing. Without the pin the mutant differs from the subject in a second way
and the comparison stops being about the mutated clause.
And it is a FUNCTION rather than a module-level dict because `conftest.py`'s autouse
`isolate_hook_fire_log` fixture monkeypatches `ETV_HOOK_FIRE_LOG_DIR` into `os.environ` at test
setup which happens AFTER this module is imported. A `{**os.environ}` snapshot taken at import
time captures the environment as it was before the fixture ran, so every hook subprocess writes
to the REAL `$HOME/.cache/ersatztv/hook-fire/` log instead of the fixture's tmp dir. That is not
untidiness: it is #776's defect reintroduced in the file that is meant to prove #776's hooks,
and it corrupts the `hook-fire-log.sh report` surface this repo cites as the observability claim
for every guard still graded NONE. The reproduction, rather than a figure whose evidence has
since been deleted: reintroduce the snapshot and run this file, then count records for the two
synthetic session ids below 58 per run, on macOS and Linux alike.
"""
return {**os.environ, "CLAUDE_PROJECT_DIR": str(REPO_ROOT)}
def _git(cwd: Path, *args: str) -> str:
p = subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
text=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@e",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@e",
},
)
return p.stdout
def _scratch_repo(tmp_path: Path) -> Path:
repo = tmp_path / "main-tree"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main", ".")
(repo / "f.txt").write_text("one\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-qm", "init")
return repo
def _run(hook: Path, payload: dict, cwd: Path) -> tuple[int, bytes]:
p = subprocess.run(
["bash", str(hook)],
input=json.dumps(payload).encode(),
capture_output=True,
cwd=str(cwd),
env=_env(),
timeout=60,
)
return p.returncode, p.stdout
def _add_worktree(repo: Path, name: str, marker_hook: Path | None, session: str) -> Path:
"""`git worktree add` exactly as a session does it, then fire the PostToolUse marker hook.
The marker hook is driven with the payload the harness would hand it AFTER the command
succeeded, which is when PostToolUse fires not a hand-planted marker file. A hand-planted
marker would make every deny below a test of the guard alone, and the untested seam is the
handoff between the two files.
"""
wt = repo.parent / name
_git(repo, "worktree", "add", "-q", str(wt))
if marker_hook is not None:
rc, _ = _run(
marker_hook,
{
"session_id": session,
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"cwd": str(repo),
"tool_input": {"command": f"git worktree add {wt}"},
},
repo,
)
assert rc == 0, "the marker hook must always exit 0"
return wt
def _commit_payload(session: str, cwd: Path, command: str = "git commit -m x") -> dict:
return {
"session_id": session,
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"cwd": str(cwd),
"tool_input": {"command": command},
}
def _denied(out: bytes) -> bool:
return b'"permissionDecision": "deny"' in out or b'"permissionDecision":"deny"' in out
# ------------------------------------------------------------------------------------------------
# ANTI-VACUITY — if the fixture never produces a marked worktree, every deny below is meaningless
# ------------------------------------------------------------------------------------------------
def test_the_subprocess_env_CARRIES_the_isolated_hook_fire_log_dir():
"""The hooks these tests drive must log to the fixture's dir, never the production one.
`conftest.py`'s autouse `isolate_hook_fire_log` monkeypatches `ETV_HOOK_FIRE_LOG_DIR` into
`os.environ` at test setup. Anything that snapshots `os.environ` at IMPORT time captures the
value from before the fixture ran and silently defeats it the hooks then append to
`$HOME/.cache/ersatztv/hook-fire/`, which is #776's defect reintroduced inside the file that
proves #776's hooks, corrupting the one surface this repo cites as the observability claim for
every guard still graded NONE.
It is invisible from the outside: the tests pass either way, because the fire-log library is
fail-open by design. So it needs its own assertion.
"""
env = _env()
isolated = os.environ.get("ETV_HOOK_FIRE_LOG_DIR")
assert isolated, "the autouse isolation fixture did not run; conftest.py is not being loaded"
assert env.get("ETV_HOOK_FIRE_LOG_DIR") == isolated, (
"the subprocess environment does not carry the isolated log dir, so every hook driven by "
"this file is writing into the production hook-fire log. Build the env per call; do not "
f"snapshot os.environ at import time. env has {env.get('ETV_HOOK_FIRE_LOG_DIR')!r}"
)
# The path `hook-fire-log.sh` falls back to when ETV_HOOK_FIRE_LOG_DIR is unset, derived the
# same way it derives it rather than restated as a literal.
production = Path(os.environ.get("HOME", "/tmp")) / ".cache" / "ersatztv" / "hook-fire" # noqa: S108 — mirrors hook-fire-log.sh's own ${HOME:-/tmp}
assert Path(isolated).resolve() != production.resolve(), (
f"the 'isolated' log dir IS the production one ({production}), so the fixture is isolating "
"nothing and this test would pass while the leak continued"
)
def test_driving_a_hook_LANDS_its_records_in_the_ISOLATED_dir(tmp_path):
"""The invariant, asserted at the EFFECT rather than at the helper that is supposed to produce it.
`test_the_subprocess_env_CARRIES_...` above checks `_env()`'s return value, and that is not the
same claim: `_env()` can be perfectly correct while a call site passes something else. Cold
review demonstrated exactly that restore the module-level snapshot and change one `env=_env()`
back to `env=_ENV`, and all thirteen tests pass while 54 records leak into the real log. The
guard was pinned to the shape of the fix instead of to the property, which is
`verify-against-the-REAL-predecessor`: a hand-written revert is not the code a future tidy-up
produces.
So this drives a real hook through the real `_run()` and asserts the records landed where the
fixture put them.
ITS SCOPE, stated because the first version of this docstring claimed more than it delivers: it
guards THE LAUNCH PATH IT DRIVES, not the file. Cold review demonstrated the gap add a second
launcher alongside `_run()` that passes a stale snapshot and point the mutation tests at it, and
this test stays green while 18 records leak, because the hooks IT drives still log correctly.
Every hook in this file goes through `_run()` today, which is what makes the guard sufficient
HERE and not a general property. The general form is a suite-level check, tracked in #809; the
reason it is hard is that the obvious version diff the production log around each test races
against a real session's hooks firing concurrently.
"""
isolated = Path(os.environ["ETV_HOOK_FIRE_LOG_DIR"])
before = {p.name for p in isolated.glob("*.jsonl")} if isolated.exists() else set()
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt), wt)
# Anti-vacuity: if the hook decided nothing, it may simply have had nothing to log.
assert rc == 0 and _denied(out), f"the hook reached no decision, so 'records landed' would prove nothing: {out!r}"
after = {p.name for p in isolated.glob("*.jsonl")} if isolated.exists() else set()
assert after > before, (
f"driving two hooks added no record to the isolated log dir {isolated}. Either the "
"instrumentation stopped firing, or these hooks are logging somewhere else — and the only "
"somewhere else is the production log this file must never touch"
)
def test_the_marker_hook_really_marks_the_worktree_it_was_told_about(tmp_path):
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
marker = wt / MARKER_NAME
assert marker.is_file(), (
f"no {MARKER_NAME} in {wt}. Without it the guard has nothing to read and every 'denied' "
"assertion in this file would be testing a mechanism that is not there"
)
assert marker.read_text().strip() == SESSION_A, (
f"the marker names {marker.read_text().strip()!r}, not the session that created the "
"worktree — ownership would be attributed to the wrong session"
)
def test_the_marker_hook_ignores_a_command_that_is_not_a_worktree_add(tmp_path):
"""The write side's own negative control: a hook that marked on any command would pass above."""
repo = _scratch_repo(tmp_path)
wt = repo.parent / "wt-unrelated"
_git(repo, "worktree", "add", "-q", str(wt))
rc, _ = _run(
MARKER_HOOK,
{
"session_id": SESSION_A,
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"cwd": str(repo),
"tool_input": {"command": f"ls {wt}"},
},
repo,
)
assert rc == 0
assert not (wt / MARKER_NAME).exists(), "the marker hook stamped a worktree it never created"
# ------------------------------------------------------------------------------------------------
# THE PAIR DECIDES — both halves, in sequence, as the harness fires them
# ------------------------------------------------------------------------------------------------
def test_a_commit_in_ANOTHER_sessions_worktree_is_DENIED(tmp_path):
"""The #289 case the mechanism exists for, end to end across both files."""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt), wt)
assert rc == 0, "the hook communicates by printing, and must always exit 0"
assert _denied(out), f"a commit into session A's worktree was not denied from session B: {out!r}"
assert SESSION_A.encode() in out, (
"the deny reason must name the owning session — without it the operator cannot tell whether "
f"the marker is stale or the worktree is genuinely foreign: {out!r}"
)
def test_a_commit_in_MY_OWN_worktree_is_ALLOWED(tmp_path):
"""Negative control. A guard that denied every marked worktree would pass the test above."""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_A, wt), wt)
assert rc == 0
assert out == b"", f"the owning session was blocked from committing in its own worktree: {out!r}"
def test_an_UNMARKED_worktree_is_ALLOWED(tmp_path):
"""The deliberate fail-open: pre-convention worktrees and the main tree carry no marker."""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-none", None, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt), wt)
assert rc == 0
assert out == b"", f"an unmarked worktree was blocked, which breaks the main tree too: {out!r}"
def test_a_NON_MUTATING_git_command_in_a_foreign_worktree_is_ALLOWED(tmp_path):
"""Only `commit`/`merge` are guarded; `git status` in a sibling worktree is normal work."""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt, "git status"), wt)
assert rc == 0
assert out == b"", f"a read-only git command was denied: {out!r}"
def test_a_MERGE_in_a_foreign_worktree_is_DENIED(tmp_path):
"""The other half of the guarded alternation.
Every other deny case here uses `git commit`, so `merge` could be dropped from the detection
regex and this file would stay green the mechanism guards the plumbing-merge path
(`process.foreign-worktree-plumbing-merge`) specifically, which makes that the more damaging
half to lose.
"""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt, "git merge --no-ff topic"), wt)
assert rc == 0
assert _denied(out), f"a merge into session A's worktree was not denied: {out!r}"
def test_a_git_C_into_a_foreign_worktree_is_DENIED_from_the_main_tree(tmp_path):
"""The redirection that makes the guard non-trivial.
The session's cwd is its OWN tree — where committing is fine — and only the `-C` argument moves
the operation into the foreign worktree. A guard that looked at `cwd` alone would allow this,
and `git -C` is how the sibling-worktree commit actually gets typed.
"""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, repo, f"git -C {wt} commit -m x"), repo)
assert rc == 0
assert _denied(out), f"`git -C <foreign worktree> commit` was not denied: {out!r}"
# ------------------------------------------------------------------------------------------------
# MUTATION PROOFS — four clauses, one per thing the mechanism hangs on
#
# Each mutant is an isolated copy with ONE clause disarmed, and each test asserts the UNMUTATED pair
# reaches the opposite decision on the same fixture FIRST. Without that positive control a mutation
# proof passes when the mechanism detects nothing at all, which is how the BOM guard sat fail-open
# for months while reading as covered.
# ------------------------------------------------------------------------------------------------
def _mutate(src: Path, tmp_path: Path, old: str, new: str, why: str) -> Path:
assert old in src.read_text(), (
f"the clause {old!r} has moved in {src.name}; RETARGET this mutation rather than loosening "
f"it — a mutation that silently stops mutating is the failure this file is about ({why})"
)
dst = tmp_path / f"mutated-{src.name}"
dst.write_text(src.read_text().replace(old, new, 1))
return dst
def test_MUTATION_disarming_the_guards_MARKER_READ_stops_the_deny(tmp_path):
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
payload = _commit_payload(SESSION_B, wt)
rc_live, out_live = _run(GUARD, payload, wt)
assert rc_live == 0 and _denied(out_live), (
"the UNMUTATED guard did not deny, so 'the mutant is silent' would prove nothing about the "
f"marker read: {out_live!r}"
)
mutant = _mutate(
GUARD,
tmp_path,
'marker="$root/.claude-worktree-owner"',
'marker="$root/.claude-worktree-owner-NOTHING-WRITES-THIS"',
"the guard's marker read",
)
rc, out = _run(mutant, payload, wt)
assert rc == 0
assert out == b"", (
"the guard still denied with its marker read pointed at a file nothing writes, so the deny "
f"is not coming from ownership at all: {out!r}"
)
def test_MUTATION_inverting_the_OWNERSHIP_COMPARISON_blocks_the_owner(tmp_path):
"""The allow direction, which the deny mutation above cannot reach.
Disarming the comparison the other way would only make the guard deny more, and every deny
assertion in this file would stay green. Inverting it is what shows the comparison rather than
the mere presence of a marker is what decides.
"""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
own_payload = _commit_payload(SESSION_A, wt)
rc_live, out_live = _run(GUARD, own_payload, wt)
assert rc_live == 0 and out_live == b"", (
f"the UNMUTATED guard already blocked the owner, so the inversion below proves nothing: {out_live!r}"
)
mutant = _mutate(
GUARD,
tmp_path,
'[ "$owner" = "$me" ] && exit 0',
'[ "$owner" != "$me" ] && exit 0',
"the guard's ownership comparison",
)
rc, out = _run(mutant, own_payload, wt)
assert rc == 0
assert _denied(out), (
"inverting the ownership comparison did not change the decision for the OWNING session, so "
f"the comparison is not what allows it through: {out!r}"
)
def test_MUTATION_a_marker_hook_that_stops_WRITING_makes_the_guard_go_quiet(tmp_path):
"""THE CROSS-FILE PROOF — the one that could not exist while the halves were tested apart.
The clause disarmed here is in `posttooluse-worktree-marker.sh`; the assertion is about
`pretooluse-worktree-guard.sh`. A regression in the write half is otherwise completely silent:
the marker hook exits 0 either way, and the guard's fail-open turns a missing marker into an
allowed commit that looks exactly like a correctly allowed one.
"""
repo = _scratch_repo(tmp_path)
wt_live = _add_worktree(repo, "wt-live", MARKER_HOOK, SESSION_A)
rc_live, out_live = _run(GUARD, _commit_payload(SESSION_B, wt_live), wt_live)
assert rc_live == 0 and _denied(out_live), (
f"the UNMUTATED pair did not deny, so a silent mutant proves nothing about the marker write: {out_live!r}"
)
mutant_marker = _mutate(
MARKER_HOOK,
tmp_path,
'printf \'%s\\n\' "$me" > "$abs/.claude-worktree-owner" 2>/dev/null || true',
"true",
"the marker hook's write",
)
wt_dead = _add_worktree(repo, "wt-dead", mutant_marker, SESSION_A)
assert not (wt_dead / MARKER_NAME).exists(), (
"the mutated marker hook wrote a marker anyway — the mutation did not disarm the write, so "
"the assertion below would be about nothing"
)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt_dead), wt_dead)
assert rc == 0
assert out == b"", (
"the guard denied a commit in a worktree that carries NO marker, which means the deny in "
f"the live case above is not evidence that the two halves are connected: {out!r}"
)
def test_MUTATION_dropping_MERGE_from_the_detection_clause_stops_denying_a_merge(tmp_path):
"""The alternation is two guarded operations, and losing one of them is silent.
This mutation is deliberately narrow: it must stop the guard denying a `merge` while leaving it
denying a `commit`. Asserting both is what distinguishes "the alternation is load-bearing" from
"the mutant broke the regex", which would redden everything and prove nothing about `merge`.
"""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
merge_payload = _commit_payload(SESSION_B, wt, "git merge --no-ff topic")
commit_payload = _commit_payload(SESSION_B, wt)
rc_live, out_live = _run(GUARD, merge_payload, wt)
assert rc_live == 0 and _denied(out_live), (
f"the UNMUTATED guard did not deny a merge, so a silent mutant proves nothing: {out_live!r}"
)
mutant = _mutate(
GUARD,
tmp_path,
"(commit|merge)\\b",
"(commit)\\b",
"the command-detection alternation",
)
rc, out = _run(mutant, merge_payload, wt)
assert rc == 0
assert out == b"", f"dropping `merge` from the alternation did not stop the merge being denied: {out!r}"
rc_c, out_c = _run(mutant, commit_payload, wt)
assert rc_c == 0 and _denied(out_c), (
"the mutant stopped denying COMMITS too, so it broke detection wholesale rather than "
f"removing the merge alternative — this proves nothing about `merge`: {out_c!r}"
)
-120
View File
@@ -1,120 +0,0 @@
"""The authoritative population for a guard whose members are FILES: the git index (ersatztv#806).
`testing.guard-derives-population-from-source` (#774) says a completeness guard derives its
population from a machine-readable authoritative source, and its worked examples are an enum and the
generated OpenAPI document. It does not say what to do when the population is *files*, and every
guard in this repo answered that with a filesystem walk. **A filesystem walk is not an authoritative
source.** It reports build output, editor droppings and whatever else happens to be on disk, and it
differs per machine, so a guard derived from it asserts a different population in CI than on the
laptop of the person it is supposed to stop.
#778 got that wrong three times in one PR, each time with an argument for why the traversal
sufficed, and the third shape is the one that motivates this module:
* a **content filter** on outbound-network tokens that omitted `git fetch`, making a hook that
fetches `origin/main` and derives a push decision structurally invisible;
* a **non-recursive `Path.glob`**, missing four nested files, one of which calls a live ErsatzTV
API and acts on the reply;
* **`Path.rglob`**, which then enumerated `.husky/_/` 17 husky shims generated by `npm ci` via
`web/package.json`'s `prepare` script, gitignored (`.husky/_/.gitignore` is `*`) and untracked.
That made the guard **RED on every developer checkout and GREEN in CI**, whose `script-tests`
job checks out and pip-installs but never runs `npm ci`. A guard that fails everywhere except
where it runs teaches its readers to ignore it.
The index holds the same set of files every checkout receives from a clone, and excludes untracked
generated files **by construction** rather than by an exclusion list somebody has to maintain and
keep correct. It is not immutable and it is per-worktree; the claim is not that it never changes,
but that it changes only through a deliberate git operation staging, a checkout, a reset, a
merge whereas the disk changes whenever a build runs.
Note what that buys over `.gitignore`-awareness: `.husky/_/` happens to carry its own `.gitignore`,
but a stray untracked `foo.sh` in `.claude/hooks/` carries nothing, and only the index knows it is
not part of the repo.
**This is not "replace every glob".** The question per guard is whether it makes a COMPLETENESS
claim over tracked files. If it does, the index is the authoritative source. If it does not a
fixture copying files into a tmp tree, a walk selecting the SUBJECT of a per-member property say
so in the guard and leave it, per the boundary #774 already draws between scope and population.
`git ls-files` lists INDEX entries, so a file deleted in the working tree but not yet staged is
still reported. That is deliberate: callers that read member contents assert existence with their
own message rather than filtering, because filtering is what makes a missing member unrepresentable.
"""
from __future__ import annotations
import fnmatch
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def _git_ls_files() -> list[str]:
"""Every path git tracks, as repo-relative posix strings.
The single place this package shells out to git, so a regression proof can narrow the tracked
set once and have every derivation built on it react.
Fails LOUDLY on an empty result rather than returning it: an empty population makes every
completeness assertion downstream pass vacuously, which is the exact failure these guards exist
to prevent.
"""
proc = subprocess.run(
["git", "-C", str(REPO_ROOT), "ls-files", "-z"],
capture_output=True,
check=False,
)
# `check=True` would raise "returned non-zero exit status 128" and leave git's own diagnostic
# trapped in `e.stderr`. The two real triggers — a tree that is not a repository, and CI's
# `detected dubious ownership` — are both diagnosable ONLY from that text, and every module
# that imports this one would otherwise die with the same inscrutable line.
assert proc.returncode == 0, (
f"`git ls-files` failed in {REPO_ROOT} (exit {proc.returncode}). Every file population here "
f"comes from the index, so this is fatal rather than empty. git said:\n"
f"{proc.stderr.decode(errors='replace').strip() or '(no stderr)'}"
)
paths = [p for p in proc.stdout.decode().split("\0") if p]
assert paths, (
f"`git ls-files` reported nothing under {REPO_ROOT} — the derivation is broken, not the "
"repo. Every file population built on it would be empty and every completeness assertion "
"would pass vacuously."
)
return paths
def tracked_children(directory: str, patterns: tuple[str, ...]) -> set[str]:
"""Tracked files that are DIRECT children of `directory` and match one of `patterns`.
Direct children only, and that is the point rather than a limitation: every population here is
a flat directory (`.claude/hooks/*.sh`, `.husky/*`, `.gitea/workflows/*.yml`,
`scripts/tests/test_*.py`), and recursing is what dragged `.husky/_/` in. A guard that genuinely
needs a nested population should say so and ask for it explicitly.
Returns repo-relative posix paths, matching what the guards' inventories and error messages use.
"""
found: set[str] = set()
for path in _git_ls_files():
parent, _, name = path.rpartition("/")
if parent != directory:
continue
if any(fnmatch.fnmatch(name, pattern) for pattern in patterns):
found.add(path)
return found
def tracked_paths(directory: str, patterns: tuple[str, ...]) -> list[Path]:
"""`tracked_children` as absolute `Path`s, sorted — for guards that read member contents.
Existence is ASSERTED, never filtered: a path in the index with no file on disk means the tree
is mid-edit, and reporting that is strictly better than silently shrinking the population, which
is the defect this module exists to remove.
"""
paths = []
for rel in sorted(tracked_children(directory, patterns)):
absolute = REPO_ROOT / rel
assert absolute.is_file(), (
f"git tracks {rel} but there is no file there. The population comes from the index, so "
"a working tree mid-delete is reported rather than silently shrinking the population."
)
paths.append(absolute)
return paths
+2 -3
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
@@ -61,7 +60,7 @@ export function getBlockItemsWithMeta(id: number): Promise<ResponseWithMeta<Bloc
*/
export function replaceBlock(
id: number,
body: Complete<ReplaceBlockRequest>,
body: ReplaceBlockRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<BlockWithItems>> {
return requestWithMeta<BlockWithItems>(`/api/v1/blocks/${id}`, {
@@ -71,7 +70,7 @@ export function replaceBlock(
});
}
export function previewBlock(id: number, body: Complete<ReplaceBlockRequest>): Promise<BlockPreviewItem[]> {
export function previewBlock(id: number, body: ReplaceBlockRequest): Promise<BlockPreviewItem[]> {
return request<BlockPreviewItem[]>(`/api/v1/blocks/${id}/preview`, { body, method: 'POST' });
}
-146
View File
@@ -1,146 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Complete } from './completeRequest';
import type { components } from './generated/v1';
/**
* #807 guard: the mutation proof for `Complete<T>` (`./completeRequest.ts`).
*
* `Complete<T>` is enforced by the COMPILER, not at runtime, so a vitest assertion cannot prove it
* works a passing `expect` here would say nothing about whether the type still rejects an
* incomplete request. The proof is therefore the `@ts-expect-error` directives below, and it is a
* real one in the sense `testing.guard-ships-with-mutation-proof` requires: each directive
* INTRODUCES THE DEFECT the guard exists to catch, and `tsc` fails the build if the defect is NOT
* reported. A `@ts-expect-error` on a line that compiles cleanly is itself an error
* (`TS2578: Unused '@ts-expect-error' directive`). That mutation runs on every
* `npm run typecheck`, which is a marked CI step (`docker-build.yml` `ci-step-ran.sh mark
* typecheck`), rather than being executed once by hand and asserted in prose.
*
* Which mutation reddens which case, measured 2026-08-22 against THIS file and not generalised:
*
* | mutation of `Complete<T>` | cases that go red |
* |---|---|
* | `{ [K in keyof T]: T[K] }` (drop `Required` the realistic weakening) | 2 and 4 |
* | `T` (delete the mapped type) | 2 and 4 |
* | `Partial<T>` | 2 and 4 |
*
* Cases 2 and 4 are the load-bearing pair one synthetic, one a real generated schema. Cases 1, 3
* and 5 stay green under all three BY DESIGN: 1 and 3 are controls that must stay green, and 5's
* excess-property check does not depend on `Required`. So do not read "5 cases, 2 red" as thin
* coverage, and equally do not restate this as "every case reddens under any weakening" that is
* false, and a proof that overstates its own reach stops being re-examined.
*
* Case 4 reddened under `Partial<T>` ONLY until it was re-pinned from a required member to a
* genuinely optional one; see its own comment for why that distinction is the whole game.
*
* What this file does NOT prove is that `Complete<T>` is APPLIED at every site that needs it.
* Reverting one screen to its pre-#807 form leaves this guard green the population of
* construction sites is not derived from anything. That residue is tracked, not implied closed.
*
* Both directions are covered, because a MISSING field and a PHANTOM field are opposite defects:
* cases 2 and 4 are the missing direction (the #807 defect proper a silently dropped field on a
* full-replace write), case 5 is the phantom direction.
*
* Cases 1-3 use a SYNTHETIC type so the proof cannot be invalidated by an unrelated schema change,
* and cases 4-5 use a REAL generated request type so the proof is demonstrably wired to
* `./generated/v1.d.ts` rather than only to a local fixture. Both are needed: the synthetic case
* alone would pass even if the generated types stopped being importable here, and the real case
* alone would break for reasons that have nothing to do with `Complete<T>`.
*
* Which real schemas carry a genuinely optional member is DERIVED, not listed here or anywhere
* else in prose: `scripts/tests/test_optional_request_members.py` computes it from the OpenAPI
* document every run and fails until each has a stated disposition. Three hand-written versions of
* that list were wrong (#807), so do not add a fourth to this comment.
*
* Note the deliberate shape of the synthetic type: `optionalMember?` is what a schema property
* emits as when it is absent from its `required` array in the OpenAPI document. Most request
* properties are not, which is why most builders already typecheck and the gap reads as closed on
* inspection. Case 2 is the whole point of this guard: it is the case that compiles clean WITHOUT
* `Complete<T>`, which is why case 3 asserts exactly that. See `./completeRequest.ts`.
*/
type SyntheticRequest = {
requiredMember: string;
optionalMember?: number;
nullableMember: string | null;
};
// Case 1 — every member named, the optional one explicitly `undefined`: MUST compile.
// This is not filler. If `Complete<T>` were written so that an optional member had to carry a
// real value, every builder would be forced to invent one, and the guard would be abandoned.
const case1: Complete<SyntheticRequest> = {
requiredMember: 'x',
optionalMember: undefined,
nullableMember: null
};
// Case 2 — the OPTIONAL member omitted: MUST be an error. This is #807's defect exactly.
// @ts-expect-error omitting an optional member of a Complete<T> request must not compile
const case2: Complete<SyntheticRequest> = {
requiredMember: 'x',
nullableMember: null
};
// Case 3 — the SAME omission against the bare type: MUST compile.
// This is the negative control, and without it cases 2 and 4 prove nothing: it demonstrates that
// the error in case 2 comes from `Complete<T>` and not from some other property of the object.
// It is the "compiles clean and is silently dropped" state #807 was filed about.
const case3: SyntheticRequest = {
requiredMember: 'x',
nullableMember: null
};
// Case 4 — a REAL generated request type with a genuinely OPTIONAL member omitted: MUST error.
//
// `MultiCollectionItemRequest.weight` sits outside its schema's `required` array (it is a defaulted
// ctor param), so it emits as `weight?: number` and omitting it is legal against the bare type.
// That makes this case DISCRIMINATING: it goes red under the realistic weakening
// `{ [K in keyof T]: T[K] }`, not only under `Partial<T>`. It is also the exact field whose loss
// was live before #807 — dropping it from `MultiCollectionsScreen.toItemRequest` typechecked clean
// and would have reset every weight to 1 on the next full-replace save.
//
// An earlier version of this case pinned `ReplaceDecoTemplateRequest.name`, a REQUIRED member, and
// then reasoned in a comment that no real request type could discriminate "because none has an
// optional member". That was false when written — `scripts/tests/test_optional_request_members.py`
// derives the schemas that do, and there are several — and it cost the proof its most valuable
// case. No count is given here on purpose: that population is derived, and every hand-written
// version of it on this issue has been wrong. Do not re-pin this to a required member: check
// the schema's `required` array
// first, and prefer a member that a builder could actually drop.
//
// If `weight` ever becomes required or disappears, this directive goes unused and tsc reports it.
// That is the correct outcome — re-pin to another genuinely optional member rather than deleting
// the case.
// @ts-expect-error omitting the optional `weight` from a real
// Complete<MultiCollectionItemRequest> must not compile
const case4: Complete<components['schemas']['MultiCollectionItemRequest']> = {
collectionId: 1,
smartCollectionId: null,
scheduleAsGroup: false,
playbackOrder: 'Chronological'
};
// Case 5 — the PHANTOM direction: a field the schema does not accept: MUST be an error.
// The directive sits on the OFFENDING PROPERTY, not on the declaration: an excess-property error
// (TS2353) is reported at the property, whereas a missing-property error (TS2741, case 4) is
// reported at the declaration. Placing it on the declaration instead made tsc report the directive
// as unused AND the excess property as an error — two reds, not a proof.
const case5: Complete<components['schemas']['ReplaceDecoTemplateRequest']> = {
name: 'x',
items: [],
// @ts-expect-error a field absent from the schema must not compile
fieldTheSchemaDoesNotHave: 1
};
describe('#807 Complete<T> request guard', () => {
it('is proven by the @ts-expect-error directives above, which npm run typecheck executes', () => {
// These runtime assertions exist only so the compile-time cases are REFERENCED. Without a use,
// `noUnusedLocals`/lint could remove them and the proof would vanish silently — the exact
// "a guard that never executed proves nothing" failure this repo has shipped before (#751).
// They deliberately assert almost nothing about behaviour: the guard is the compiler.
expect(case1.requiredMember).toBe('x');
expect(case3.requiredMember).toBe('x');
expect(case2).toBeDefined();
expect(case4).toBeDefined();
expect(case5).toBeDefined();
});
});
-70
View File
@@ -1,70 +0,0 @@
/**
* `Complete<T>` a request type with every member REQUIRED, so a builder that forgets one
* fails `npm run typecheck` instead of silently dropping the field (issue #807).
*
* ## The defect this closes
*
* The SPA builds write-request bodies field-by-field as object literals against the generated
* schema types in `./generated/v1.d.ts`. Those bodies are sent to full-replace endpoints, so a
* field the builder never sets is not "left alone" it is written as its default. That is #754's
* mechanism: a hand-maintained mirror drifts from a DTO by one field, the write returns HTTP 200,
* and the loss surfaces hours later. See `testing.full-replace-asserts-field-list`.
*
* This is not hypothetical. Measured 2026-08-22, before this change: deleting
* `weight: clampWeight(item.weight)` from `MultiCollectionsScreen.toItemRequest` typechecked
* CLEAN, and that PUT replaces the item list so every weight would have reset to 1 on the next
* save. The screen carries a prose comment warning about exactly that; a comment is not a check.
* `FFmpegProfilesScreen` had the same exposure on `qsvPreferNativeDecoder`.
*
* ## What determines whether the compiler can see it
*
* A member is omittable exactly when the generated type marks it `?:`, and that comes from the
* `required` array of the schema in `ErsatzTV/wwwroot/openapi/v1.json`.
* `web/scripts/generate-openapi-types.mjs` is a pure pass-through its whole contribution is
* `const optional = required.has(name) ? '' : '?'` so the determinant is the ASP.NET-produced
* OpenAPI document, NOT the generator script. A property lands outside `required` because of how
* its DTO is modelled typically a defaulted constructor parameter, as `weight` and
* `qsvPreferNativeDecoder` are. Do not maintain a count of such properties here: two hand-written
* lists of them were wrong (#807), and `scripts/tests/test_optional_request_members.py` derives
* the current set from the OpenAPI document on every run.
*
* NOT EVERY optional member is a droppable field. Where the optional members are computed get-only
* properties on the C# record `ArtworkContentTypeModel`'s `IsExternalUrl`, `HasContentType`,
* `UrlWithContentType` nothing deserializes them, so omitting them drops nothing, and applying
* `Complete<T>` there would be a BUG: it would force a caller to fabricate server-computed values
* in an outbound request. That test file records the disposition per schema.
*
* Note the direction of the trap: a nullable property usually emits as REQUIRED-and-nullable
* (`"name": null | string`), so most builders are checked and the gap looks closed on inspection.
* The unchecked ones are a small minority hiding inside a large majority of checked ones.
*
* ## Both directions
*
* - MISSING (a schema field the builder never sets) `Complete<T>` makes it a hard error, and it
* does so wherever the value is assigned, including through a spread or an inferred local.
* - PHANTOM (a field the builder sets that the schema does not accept) TypeScript's excess
* property check already reports this, but ONLY for a "fresh" object literal in a typed
* position. Returning a literal from a generic `.map` callback is NOT such a position: `map<U>`
* infers `U` FROM the callback's return, so the target element type never contextually types the
* literal, and the check does not fire with or without a spread in it. Measured on the
* pre-#807 tree: several construction sites accepted a phantom field, three of them with no
* spread and no inferred local. Annotating each site's return type is what restores this
* direction. (No tally: "construction site" is not a derived population, and the count in an
* earlier draft was wrong.)
*
* ## Explicit `undefined` is still allowed, deliberately
*
* `T[K]` is preserved, so an optional member may be written as `field: undefined`. The point is
* not to forbid omitting a value, it is to forbid omitting the DECISION an unmentioned field is
* an oversight, `field: undefined` is a choice a reviewer can see.
*
* ## Shallow, and what that costs
*
* `Complete<T>` does not recurse into `items: Array<ItemRequest>`; each nested item builder is
* annotated `Complete<ItemRequest>` directly instead. This is a scope choice, not a claim that a
* deep variant is infeasible probed 2026-08-22, the shallow mapped form distributes correctly
* over unions, leaves `number[]` an array, preserves `readonly`, and passes an index signature
* through, so a deep variant is not obviously blocked. A NEW nested request type therefore needs
* its own annotation and nothing forces that; see the residue noted on the decision record.
*/
export type Complete<T> = { [K in keyof Required<T>]: T[K] };
+1 -2
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
@@ -58,7 +57,7 @@ export function getDecoTemplateItemsWithMeta(id: number): Promise<ResponseWithMe
*/
export function replaceDecoTemplate(
id: number,
body: Complete<ReplaceDecoTemplateRequest>,
body: ReplaceDecoTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<DecoTemplateWithItems>> {
return requestWithMeta<DecoTemplateWithItems>(`/api/v1/deco-templates/${id}`, {
+1 -2
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
@@ -43,7 +42,7 @@ export function deleteDeco(id: number): Promise<void> {
return request<void>(`/api/v1/decos/${id}`, { method: 'DELETE' });
}
export function replaceDeco(id: number, body: Complete<ReplaceDecoRequest>): Promise<Deco> {
export function replaceDeco(id: number, body: ReplaceDecoRequest): Promise<Deco> {
return request<Deco>(`/api/v1/decos/${id}`, { body, method: 'PUT' });
}
+1 -3
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createFFmpegProfile,
@@ -20,7 +19,7 @@ function noContent(): Response {
return new Response(null, { status: 204 });
}
const sampleRequest: Complete<CreateFFmpegProfileRequest> = {
const sampleRequest: CreateFFmpegProfileRequest = {
allowBFrames: false,
audioBitrate: 192,
audioBufferSize: 384,
@@ -38,7 +37,6 @@ const sampleRequest: Complete<CreateFFmpegProfileRequest> = {
normalizeVideo: true,
padMode: 'Software',
qsvExtraHardwareFrames: null,
qsvPreferNativeDecoder: true,
resolutionId: 1,
scalingBehavior: 'ScaleAndPad',
tonemapAlgorithm: 'Linear',
+2 -3
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
@@ -15,11 +14,11 @@ export function getFFmpegProfile(id: number): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>(`/api/v1/ffmpeg/profiles/${id}`);
}
export function createFFmpegProfile(body: Complete<CreateFFmpegProfileRequest>): Promise<FFmpegProfileDetail> {
export function createFFmpegProfile(body: CreateFFmpegProfileRequest): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>('/api/v1/ffmpeg/profiles', { body, method: 'POST' });
}
export function updateFFmpegProfile(id: number, body: Complete<UpdateFFmpegProfileRequest>): Promise<FFmpegProfileDetail> {
export function updateFFmpegProfile(id: number, body: UpdateFFmpegProfileRequest): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>(`/api/v1/ffmpeg/profiles/${id}`, { body, method: 'PUT' });
}
-1
View File
@@ -6,7 +6,6 @@ export * from './channels';
export * from './channelTemplates';
export * from './client';
export * from './collections';
export * from './completeRequest';
export * from './dashboard';
export * from './decos';
export * from './decoTemplates';
+2 -3
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import type { RemoteFamily } from '../mediaSources/familyMeta';
@@ -102,7 +101,7 @@ export function getRemoteLibraries(family: RemoteFamily, sourceId: number): Prom
export function replaceRemoteLibraryPreferences(
family: RemoteFamily,
sourceId: number,
body: Complete<ReplaceRemoteLibraryPreferencesRequest>
body: ReplaceRemoteLibraryPreferencesRequest
): Promise<RemoteLibrary[]> {
return request<RemoteLibrary[]>(`/api/v1/media-sources/${family}/${sourceId}/libraries`, { body, method: 'PUT' });
}
@@ -114,7 +113,7 @@ export function getPathReplacements(family: RemoteFamily, sourceId: number): Pro
export function replacePathReplacements(
family: RemoteFamily,
sourceId: number,
body: Complete<ReplacePathReplacementsRequest>
body: ReplacePathReplacementsRequest
): Promise<PathReplacement[]> {
return request<PathReplacement[]>(`/api/v1/media-sources/${family}/${sourceId}/path-replacements`, {
body,
+1 -2
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
@@ -54,7 +53,7 @@ export function createMultiCollection(body: CreateMultiCollectionRequest): Promi
*/
export function updateMultiCollection(
id: number,
body: Complete<UpdateMultiCollectionRequest>,
body: UpdateMultiCollectionRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<MultiCollection>> {
return requestWithMeta<MultiCollection>(`/api/v1/multi-collections/${id}`, {
+2 -3
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
@@ -56,7 +55,7 @@ export function createPlaylist(body: CreatePlaylistRequest): Promise<Playlist> {
// the new ETag for a subsequent save (issue #253).
export function updatePlaylist(
id: number,
body: Complete<ReplacePlaylistRequest>,
body: ReplacePlaylistRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<PlaylistItem[]>> {
return requestWithMeta<PlaylistItem[]>(`/api/v1/playlists/${id}`, {
@@ -70,7 +69,7 @@ export function deletePlaylist(id: number): Promise<void> {
return request<void>(`/api/v1/playlists/${id}`, { method: 'DELETE' });
}
export function previewPlaylist(body: Complete<ReplacePlaylistRequest>): Promise<PlaylistPreviewItem[]> {
export function previewPlaylist(body: ReplacePlaylistRequest): Promise<PlaylistPreviewItem[]> {
return request<PlaylistPreviewItem[]>('/api/v1/playlists/preview', { body, method: 'POST' });
}
+2 -3
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
@@ -180,7 +179,7 @@ export function getAlternateSchedulesWithMeta(
*/
export function replaceAlternateSchedules(
playoutId: number,
body: Complete<ReplacePlayoutAlternateSchedulesRequest>,
body: ReplacePlayoutAlternateSchedulesRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<PlayoutAlternateSchedule[]>> {
return requestWithMeta<PlayoutAlternateSchedule[]>(`/api/v1/playouts/${playoutId}/alternate-schedules`, {
@@ -205,7 +204,7 @@ export function getPlayoutTemplatesWithMeta(playoutId: number): Promise<Response
*/
export function replacePlayoutTemplates(
playoutId: number,
body: Complete<ReplacePlayoutTemplatesRequest>,
body: ReplacePlayoutTemplatesRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<PlayoutTemplate[]>> {
return requestWithMeta<PlayoutTemplate[]>(`/api/v1/playouts/${playoutId}/templates`, {
+1 -2
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
// FillerPreset is exported by ./pickers — import (don't re-export) to avoid a duplicate `export *`
@@ -68,7 +67,7 @@ export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest):
// response (see SchedulesScreen.tsx `save()`), not reuse the ids it submitted.
export function replaceScheduleItems(
scheduleId: number,
body: Complete<ReplaceScheduleItemsRequest>,
body: ReplaceScheduleItemsRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<ScheduleItem[]>> {
return requestWithMeta<ScheduleItem[]>(`/api/v1/schedules/${scheduleId}/items`, {
+1 -2
View File
@@ -1,4 +1,3 @@
import type { Complete } from './completeRequest';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
@@ -59,7 +58,7 @@ export function getTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<T
*/
export function replaceTemplate(
id: number,
body: Complete<ReplaceTemplateRequest>,
body: ReplaceTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<TemplateWithItems>> {
return requestWithMeta<TemplateWithItems>(`/api/v1/templates/${id}`, {
+1 -2
View File
@@ -1,7 +1,6 @@
// Pure, exhaustively-tested rules for the schedule-item editor. Encodes the Blazor
// ProgramScheduleItemEditViewModel gating + forced-reset behavior (the parity standard for #207).
// No React, no fetch — every function is a deterministic transform so itemRules.test.ts can pin it.
import type { Complete } from '../api/completeRequest';
import type { components } from '../api/generated/v1';
export type CollectionType = components['schemas']['CollectionType'];
@@ -390,7 +389,7 @@ export function fromResponse(model: ScheduleItem): DraftItem {
// Projects a draft to the request body, applying Blazor's getter-gating: gated-off values are
// nulled (or defaulted to the enum's None) so the payload matches what a non-editing Blazor VM emits.
export function normalizeForSave(item: DraftItem): Complete<ScheduleItemRequest> {
export function normalizeForSave(item: DraftItem): ScheduleItemRequest {
const isFixed = item.startType === 'Fixed';
const isMultipleCount = item.playoutMode === 'Multiple' && item.multipleMode === 'Count';
const isDuration = item.playoutMode === 'Duration';
+2 -3
View File
@@ -13,7 +13,6 @@ import {
Trash2,
TriangleAlert
} from 'lucide-react';
import type { Complete } from '../api/completeRequest';
import { navigateToPath } from '../routing';
import {
Badge,
@@ -207,7 +206,7 @@ function itemFromResponse(item: BlockItem): DraftItem {
};
}
function toRequestItem(item: DraftItem): Complete<BlockItemRequest> {
function toRequestItem(item: DraftItem): BlockItemRequest {
return {
collectionType: item.collectionType,
collectionId: item.collectionType === 'Collection' ? item.collectionId : null,
@@ -270,7 +269,7 @@ function validate(draft: Draft): null | string {
return null;
}
function toReplaceRequest(draft: Draft): Complete<ReplaceBlockRequest> {
function toReplaceRequest(draft: Draft): ReplaceBlockRequest {
return {
name: draft.name.trim(),
minutes: draft.hours * 60 + draft.minutes,
+2 -4
View File
@@ -20,10 +20,8 @@ import {
type DecoListItem,
type DecoTemplate,
type DecoTemplateGroup,
type DecoTemplateItem,
type DecoTemplateItemRequest
type DecoTemplateItem
} from '../api';
import type { Complete } from '../api/completeRequest';
const BASE_PATH = '/app/deco-templates';
const MINUTES_PER_DAY = 24 * 60;
@@ -606,7 +604,7 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
decoTemplateId,
{
name: draft.name.trim(),
items: draft.items.map((item): Complete<DecoTemplateItemRequest> => ({
items: draft.items.map((item) => ({
decoId: item.decoId,
startTime: item.startTime,
endTime: item.endTime
+2 -3
View File
@@ -36,7 +36,6 @@ import {
type ReplaceDecoRequest,
type SchedulingPickerOption
} from '../api';
import type { Complete } from '../api/completeRequest';
import { getGraphicsElements, getWatermarks, type GraphicsElement, type Watermark } from '../api/pickers';
import { getPlaylistGroups, getPlaylists, type Playlist, type PlaylistGroup } from '../api/playlists';
@@ -207,7 +206,7 @@ function fillerIdFields(type: CollectionType, id: number | null) {
};
}
function breakToRequest(item: BreakDraft): Complete<DecoBreakContentRequest> {
function breakToRequest(item: BreakDraft): DecoBreakContentRequest {
if (item.collectionType === 'Playlist') {
return {
id: item.id,
@@ -234,7 +233,7 @@ function breakToRequest(item: BreakDraft): Complete<DecoBreakContentRequest> {
};
}
function toReplaceRequest(draft: Draft): Complete<ReplaceDecoRequest> {
function toReplaceRequest(draft: Draft): ReplaceDecoRequest {
const defaultFiller = fillerIdFields(draft.defaultFillerCollectionType, draft.defaultFillerId);
const deadAir = fillerIdFields(draft.deadAirFallbackCollectionType, draft.deadAirFallbackId);
return {
+1 -6
View File
@@ -1,4 +1,3 @@
import type { Complete } from '../api/completeRequest';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, Check, Copy, Plus, SlidersHorizontal, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
@@ -25,11 +24,7 @@ const BASE_PATH = '/app/ffmpeg-profiles';
// form must not offer one it would silently override (ersatztv#529)
const MINIMUM_QSV_EXTRA_HARDWARE_FRAMES = 64;
// `Complete<…>` so the draft must name every request member: the edit path PUTs this whole
// object to a full-replace endpoint, where an unset member is written as its default rather
// than left alone. `qsvPreferNativeDecoder` is optional in the schema and both draft builders
// happened to set it; nothing required them to (#807).
type Draft = Complete<CreateFFmpegProfileRequest>;
type Draft = CreateFFmpegProfileRequest;
/* ---------- enum option lists (mirror ErsatzTV/Pages/FFmpegEditor.razor) ---------- */
+3 -8
View File
@@ -16,10 +16,8 @@ import {
type MediaCollection,
type MultiCollection,
type MultiCollectionItemRequest,
type SmartCollection,
type UpdateMultiCollectionRequest
type SmartCollection
} from '../api';
import type { Complete } from '../api/completeRequest';
/* ---------- data hook ---------- */
@@ -139,7 +137,7 @@ function itemsFromMultiCollection(mc: MultiCollection): DraftItem[] {
);
}
function toItemRequest(item: DraftItem): Complete<MultiCollectionItemRequest> {
function toItemRequest(item: DraftItem): MultiCollectionItemRequest {
return {
collectionId: item.kind === 'manual' ? item.id : null,
playbackOrder: 'Chronological',
@@ -279,10 +277,7 @@ function MultiCollectionEditor({
setSaveError(null);
try {
const body: Complete<UpdateMultiCollectionRequest> = {
items: items.map(toItemRequest),
name: trimmedName
};
const body = { items: items.map(toItemRequest), name: trimmedName };
if (initial) {
await updateMultiCollection(initial.id, body, etagRef.current);
} else {
@@ -1,5 +1,3 @@
import type { Complete } from '../api/completeRequest';
import type { PathReplacementItemRequest } from '../api/mediaSources';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Check, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { Button, Card, Input, Spinner } from '../components';
@@ -174,13 +172,7 @@ export function PathReplacementsEditScreen({ family, sourceId }: { family: Remot
setSaving(true);
setSaveError(null);
replacePathReplacements(family, sourceId, {
items: draft.map(
(row): Complete<PathReplacementItemRequest> => ({
id: row.id,
remotePath: row.remotePath.trim(),
localPath: row.localPath.trim()
})
)
items: draft.map((row) => ({ id: row.id, remotePath: row.remotePath.trim(), localPath: row.localPath.trim() }))
})
.then((rows) => {
if (!activeRef.current) {
+3 -8
View File
@@ -44,10 +44,8 @@ import {
type PlaylistGroup,
type PlaylistItem,
type PlaylistItemRequest,
type PlaylistPreviewItem,
type ReplacePlaylistRequest
type PlaylistPreviewItem
} from '../api';
import type { Complete } from '../api/completeRequest';
import { SearchPicker } from '../schedules/pickers';
type CollectionType = PlaylistItemRequest['collectionType'];
@@ -275,7 +273,7 @@ function draftFromItem(item: PlaylistItem): DraftItem {
};
}
function toItemRequest(item: DraftItem, index: number): Complete<PlaylistItemRequest> {
function toItemRequest(item: DraftItem, index: number): PlaylistItemRequest {
const source = configFor(item.collectionType)?.source ?? 'browse';
const trimmedCount = item.count.trim();
const parsedCount = trimmedCount === '' ? null : Number(trimmedCount);
@@ -584,10 +582,7 @@ function PlaylistEditor({ playlistId, onBack, onSaved }: { playlistId: number; o
});
};
const buildRequest = (): Complete<ReplacePlaylistRequest> => ({
items: items.map(toItemRequest),
name: name.trim()
});
const buildRequest = () => ({ items: items.map(toItemRequest), name: name.trim() });
// Every item must carry a selection the API can bind. Without this the screen happily PUT an item
// with a null id — the server 422s it (`ReplacePlaylistItemsHandler.CollectionTypeMustBeValid`),
+12 -19
View File
@@ -20,11 +20,8 @@ import {
type PlayoutAlternateSchedule,
type PlayoutTemplate,
type ProgramSchedule,
type Template,
type PlayoutAlternateScheduleItemRequest,
type PlayoutTemplateItemRequest
type Template
} from '../api';
import type { Complete } from '../api/completeRequest';
const PLAYOUTS_PATH = '/app/playouts';
@@ -529,13 +526,11 @@ export function PlayoutAlternateSchedulesScreen({ playoutId }: { playoutId: numb
replaceAlternateSchedules(
playoutId,
{
items: items.map(
(item): Complete<PlayoutAlternateScheduleItemRequest> => ({
id: item.id,
programScheduleId: item.programScheduleId,
...toRequestRecurrence(item)
})
)
items: items.map((item) => ({
id: item.id,
programScheduleId: item.programScheduleId,
...toRequestRecurrence(item)
}))
},
etagRef.current
)
@@ -835,14 +830,12 @@ export function PlayoutTemplatesEditorScreen({ playoutId }: { playoutId: number
replacePlayoutTemplates(
playoutId,
{
items: items.map(
(item): Complete<PlayoutTemplateItemRequest> => ({
id: item.id,
templateId: item.templateId,
decoTemplateId: item.decoTemplateId,
...toRequestRecurrence(item)
})
)
items: items.map((item) => ({
id: item.id,
templateId: item.templateId,
decoTemplateId: item.decoTemplateId,
...toRequestRecurrence(item)
}))
},
etagRef.current
)
@@ -1,5 +1,3 @@
import type { Complete } from '../api/completeRequest';
import type { RemoteLibraryPreferenceRequest } from '../api/mediaSources';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, ArrowUpDown, Check, TriangleAlert } from 'lucide-react';
import { Button, Card, Spinner, Switch } from '../components';
@@ -173,12 +171,7 @@ export function RemoteLibrariesEditScreen({ family, sourceId }: { family: Remote
setSaving(true);
setSaveError(null);
replaceRemoteLibraryPreferences(family, sourceId, {
libraries: draft.map(
(library): Complete<RemoteLibraryPreferenceRequest> => ({
id: library.id,
shouldSyncItems: library.shouldSyncItems
})
)
libraries: draft.map((library) => ({ id: library.id, shouldSyncItems: library.shouldSyncItems }))
})
.then((libraries) => {
if (!activeRef.current) {
+2 -6
View File
@@ -21,10 +21,8 @@ import {
type BlockGroup,
type Template,
type TemplateGroup,
type TemplateItem,
type TemplateItemRequest
type TemplateItem
} from '../api';
import type { Complete } from '../api/completeRequest';
const BASE_PATH = '/app/templates';
@@ -680,9 +678,7 @@ function TemplateEditor({ templateId }: { templateId: number }) {
templateId,
{
name: draft.name.trim(),
items: draft.items.map(
(item): Complete<TemplateItemRequest> => ({ blockId: item.blockId, startTime: item.startTime })
)
items: draft.items.map((item) => ({ blockId: item.blockId, startTime: item.startTime }))
},
etagRef.current
);