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

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

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

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

564 lines
26 KiB
Python

"""Tests for the docs-only exemption path in `.claude/hooks/pretooluse-merge-consent.sh` (#622).
This covers the *file-enumeration* half of the gate, which decides whether a PR skips the
Done-when/verdict checks entirely. Three defects lived here, all silent false negatives that
widened the exemption rather than narrowing it:
1. the list was read from a single `?limit=100` page, which Gitea caps at 50 — on PR #619 that
showed zero protected paths where the full enumeration finds ten;
2. only `.filename` was read, so a `git mv` of a protected file INTO `docs/` looked docs-only;
3. a mid-pagination transport failure produced an empty page, which counted as zero rows and
read as "end of list" — completing the enumeration from a PARTIAL list.
(3) is the one worth restating: it is a defect on the FAILURE path, so every happy-path run looked
correct. These tests therefore assert what happens when a page *errors*, not only when it returns.
Observable contract: the hook exits 0 with EMPTY stdout when the PR is exempt (it passes through to
normal permissioning), and emits a JSON `permissionDecision` when it is not. So "did the exemption
apply" is directly observable without stubbing the rest of the gate.
"""
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"
# Serves paged `pulls/N/files`, plus the minimal PR object the hook reads first.
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]
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])
pages = json.loads((state / "pages.json").read_text())
if page > len(pages):
print("[]"); sys.exit(0)
entry = pages[page - 1]
if entry == "ERROR": # transport failure on this page
sys.exit(22)
if entry == "GARBAGE": # 200 with a non-array body (proxy/error page)
print('{"message":"internal error"}'); sys.exit(0)
# anything else is served verbatim as the page body (rows, [], malformed rows, scalars)
print(json.dumps(entry)); sys.exit(0)
if "/pulls/" in url:
# Optional: a second head sha served from the Nth PR-object read onward, modelling a
# force-push landing between pagination round-trips.
shas = [os.environ["STUB_SHA"]]
alt = state / "pr_sha_after.txt"
ctr = state / "pr_reads.txt"
nread = int(ctr.read_text()) if ctr.exists() else 0
ctr.write_text(str(nread + 1))
if alt.exists() and nread >= 1:
shas = [alt.read_text().strip()]
# `.base.ref` is served because the hook now reads it and threads it to the enumeration as the
# required 5th argument (ersatztv#698 route 1). Without it the hook passes an empty base, the
# script exits 2, and EVERY docs-only exemption silently stops being granted — which is exactly
# how this stub failed when the argument was added: the eight failures were all positive cases.
# Fail-closed, so not dangerous, but it would have made the advisory hook prompt on every
# docs-only PR.
#
# `.base.sha` joined it for the same reason one release later (ersatztv#707), and the symptom
# repeated almost exactly: NINE failures, every one a positive control, because the enumeration
# now binds the base's TIP across the paging window and an absent tip fails closed. Worth stating
# as a standing property of this stub rather than a second anecdote — it serves the fields the
# SHARED enumeration reads, so every new binding the script learns must be modelled here too, and
# the tell is always a wave of positive cases going red at once.
base_sha = os.environ.get("STUB_BASE_SHA", "b" * 40)
alt_base = state / "base_sha_after.txt"
if alt_base.exists() and nread >= 1:
base_sha = alt_base.read_text().strip()
print(json.dumps({"head": {"sha": shas[0]},
"base": {"ref": os.environ.get("STUB_BASE", "main"),
"sha": base_sha},
"body": "no linked issue here"}))
sys.exit(0)
print("{}")
"""
def _rows(paths):
return [{"filename": p, "status": "modified"} for p in 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()
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_URL"] = "http://gitea.example"
env.pop("ETV_GITEA_BASICAUTH", None)
class Handle:
def set_pages(self, *pages):
(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
)
def exempted(self):
"""Exempt == passthrough == exit 0 with no decision JSON."""
r = self.run()
assert r.returncode == 0, r.stderr
return r.stdout.strip() == ""
return Handle()
def test_docs_only_pr_is_exempt(hook):
hook.set_pages(_rows(["docs/a.md", "docs/b.md", "README.md"]))
assert hook.exempted() is True
def test_code_pr_is_not_exempt(hook):
hook.set_pages(_rows(["docs/a.md", "ErsatzTV/Program.cs"]))
assert hook.exempted() is False
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"]))
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
def test_rename_of_code_into_docs_is_not_exempt(hook):
"""`git mv ErsatzTV/Program.cs docs/note.md` — the code path lives only in previous_filename.
NOTE the source must be a path the hook does not already exempt. Its docs-only pattern
deliberately also covers `.claude/`/`.gitea/`/`.husky/` (process files exempt to a HUMAN
PROMPT, never to an auto-grant), so a rename between two exempt paths is correctly still
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"}]
)
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"}])
assert hook.exempted() is True
def test_transport_failure_mid_pagination_withholds_the_exemption(hook):
"""The failure-path defect: an errored page must not read as 'end of list'."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), "ERROR")
assert hook.exempted() is False
def test_non_array_body_mid_pagination_withholds_the_exemption(hook):
"""A 200 carrying an error object must not be treated as an empty final page."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), "GARBAGE")
assert hook.exempted() is False
def test_first_page_failure_withholds_the_exemption(hook):
"""NOTE this one is deliberately weak on its own — see the test below.
With page 1 failing, the path list is empty, and the hook independently withholds the
exemption on an empty list. So this passes even with the validation guard removed. It is
kept as a smoke case, but it does NOT pin the guard; `test_malformed_rows_*` does.
"""
hook.set_pages("ERROR")
assert hook.exempted() is False
def test_malformed_rows_mid_pagination_withhold_the_exemption(hook):
"""Isolates the row-schema hole: `[{}]` is a valid ARRAY whose rows carry no filename.
Validating only the top-level type lets this through — it contributes no paths, so it looks
like a short final page and completes the enumeration from a PARTIAL list. The 50 docs rows on
page 1 are what make this non-vacuous: the path list is non-empty, so the exemption would
genuinely fire if `complete` were wrongly set.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), [{}])
assert hook.exempted() is False
def test_scalar_rows_mid_pagination_withhold_the_exemption(hook):
"""An array of scalars must be rejected, not crash the extraction under `set -e`."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), ["unexpected"])
assert hook.exempted() is False
def test_empty_final_page_is_a_legitimate_end_of_pagination(hook):
"""Positive control for the guard above: `[]` must still count as a clean end, not a failure.
Without this, a stricter guard could withhold every exemption and the tests above would still
pass — the suite would be asserting 'never exempt', which is not the contract.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), [])
assert hook.exempted() is True
def test_malformed_rename_row_withholds_the_exemption(hook):
"""A row marked `renamed` with NO `previous_filename` hides its source path.
The destination alone is `docs/...`, so validating only `filename` would accept the row and
silently drop whatever it was renamed FROM — defeating the reason both sides are collected.
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"}])
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": ""}],
)
assert hook.exempted() is False
def test_ordinary_row_without_previous_filename_is_still_valid(hook):
"""Positive control: `previous_filename` must be required ONLY for rename rows.
Requiring it globally would reject every normal modified/added row and make the gate refuse
all exemptions — which the 'withholds' tests above could not distinguish from working.
Every row carries a `status`: an ABSENT status fails closed (it
would otherwise dodge the `renamed => previous_filename REQUIRED` clause), which is asserted by
`test_a_rename_disguised_by_an_unknown_status_is_rejected[None]`.
"""
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
def test_exceeding_max_pages_withholds_the_exemption(hook):
"""41 full pages: enumeration cannot be proven exhaustive, so no exemption."""
hook.set_pages(*[_rows([f"docs/p{p}f{i}.md" for i in range(50)]) for p in range(41)])
assert hook.exempted() is False
# --- #631: the guard must not depend on jq's empty-input exit status ---------------------------
#
# `jq -e` over EMPTY input exits 4 on jq >= 1.7 but 0 on jq 1.6. The pagination guard originally
# leaned on that status to reject a transport failure, so on jq 1.6 the failed page passed the
# guard, the loop walked PAST it, the next page legitimately returned `[]`, and the enumeration
# completed from a PARTIAL list — the docs-only exemption firing over unread pages.
#
# This was invisible for two compounding reasons: the suite never ran in CI at all (#631), and
# `test_transport_failure_mid_pagination_withholds_the_exemption` above only exposes it when the
# host jq happens to be 1.6 — it passes on a developer Mac (1.8.x) with the bug fully present.
# So that test cannot pin this property; this one does, on ANY jq, by putting a shim on PATH that
# reproduces the single 1.6 behaviour and nothing else.
_JQ16_SHIM = r'''#!/usr/bin/env python3
"""jq wrapper reproducing exactly one jq-1.6 behaviour: `-e` over EMPTY input exits 0 (not 4).
Deliberately narrow. The quirk applies ONLY to a `-e` invocation that reads stdin; it must not
touch `jq -n`, which the hook's `decide` uses to build its decision JSON and which legitimately
has empty stdin. An earlier, broader version of this shim swallowed those `-n` calls, so the hook
emitted nothing and every decision read as "passthrough" — the shim manufacturing the very result
the test was trying to disprove.
"""
import os, subprocess, sys
argv = sys.argv[1:]
uses_null_input = any(a in ("-n", "--null-input") for a in argv)
wants_exit_status = any(a in ("-e", "--exit-status") for a in argv)
data = b"" if uses_null_input else sys.stdin.buffer.read()
if wants_exit_status and not uses_null_input and not data.strip():
sys.exit(0) # <-- the jq 1.6 quirk under test
here = os.path.dirname(os.path.abspath(__file__))
parts = [p for p in os.environ.get("PATH", "").split(os.pathsep) if os.path.abspath(p or ".") != here]
for d in parts:
cand = os.path.join(d, "jq")
if os.path.isfile(cand) and os.access(cand, os.X_OK):
sys.exit(subprocess.run([cand, *argv], input=data).returncode)
sys.stderr.write("real jq not found\n")
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()
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_URL"] = "http://gitea.example"
env.pop("ETV_GITEA_BASICAUTH", None)
class Handle:
def set_pages(self, *pages):
(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)
assert r.returncode == 0, r.stderr
return r.stdout.strip() == ""
return Handle()
def test_jq16_shim_actually_reproduces_the_quirk(hook_jq16, tmp_path):
"""Verify the verifier: the shim must really exit 0 on empty and still work otherwise.
Without this, a shim that silently failed to install would make the test below pass
vacuously — reporting the guard safe on jq 1.6 without ever exercising the quirk."""
jq = tmp_path / "bin" / "jq"
# the quirk itself
empty = subprocess.run([str(jq), "-e", "type"], input=b"", capture_output=True)
assert empty.returncode == 0, "shim did not reproduce jq 1.6's empty-input exit 0"
# ...and everything the shim must NOT disturb
ok = subprocess.run([str(jq), "-r", "length"], input=b"[1,2,3]", capture_output=True)
assert ok.returncode == 0 and ok.stdout.strip() == b"3", f"shim broke real jq delegation: {ok}"
# `jq -n` legitimately has empty stdin and MUST still produce output — the hook's `decide`
# builds its decision JSON that way. A shim that swallowed it made every decision look like a
# passthrough, i.e. manufactured the exemption the test below is trying to disprove.
nullin = subprocess.run([str(jq), "-n", "--arg", "r", "hi", "{a:$r}"], input=b"", capture_output=True)
assert nullin.returncode == 0 and b"hi" in nullin.stdout, f"shim broke `jq -n`: {nullin}"
def test_transport_failure_withholds_exemption_even_on_jq16(hook_jq16):
"""The #631 regression: a mid-pagination transport failure must withhold the exemption on a
host whose jq returns 0 for empty input, not just on jq >= 1.7.
Page 1 = 50 docs rows (so the path list is non-empty and the exemption would genuinely fire),
page 2 = transport failure, page 3 = a legitimate empty page. Pre-fix this returned True."""
hook_jq16.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), "ERROR", [])
assert hook_jq16.exempted() is False
# --- #643 review findings: fail-opens independent of the jq version -----------------------------
def test_newline_in_filename_does_not_split_into_two_passing_paths(hook):
"""A path containing a newline must NOT be flattened into two allow-list-passing lines.
`chunk` renders paths as newline-delimited text, so `"safe.md\\ndocs/Program.cs"` becomes two
lines — `safe.md` and `docs/Program.cs` — which BOTH match the docs allow-list, while the real
single path ends in `.cs`. Git permits newlines in filenames, so this is reachable. Fail closed
on control characters."""
hook.set_pages([{"filename": "safe.md\ndocs/Program.cs", "status": "added"}])
assert hook.exempted() is False
def test_newline_in_previous_filename_is_also_rejected(hook):
"""Same hole via the rename side — `previous_filename` is flattened identically.
NOTE the payload's second segment must itself be allow-list-PASSING (`docs/Program.cs`, not
`ErsatzTV/Program.cs`). The latter is rejected by the allow-list on its own merits, so a test
using it passes with the newline guard entirely removed — it
asserts 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"}], []
)
assert hook.exempted() is False
@pytest.mark.parametrize("status", ["modified", "copied", "added"])
def test_previous_filename_is_validated_on_NON_renamed_rows_too(hook, status):
"""The validation domain must match the CONSUMPTION domain.
`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"}], [])
assert hook.exempted() is False
def test_dotdot_path_component_is_rejected(hook):
"""The allow-list anchors `^docs/`, so `docs/../ErsatzTV/Program.cs` matches it. Git will not
produce such a path, but this guard's job is to fail closed on unexpected 2xx shapes rather
than assume a well-behaved peer."""
hook.set_pages([{"filename": "docs/../ErsatzTV/Program.cs", "status": "modified"}], [])
assert hook.exempted() is False
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"}], [])
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".
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"]), [])
assert hook.exempted() is False
def test_short_page_then_empty_page_still_exempts_a_genuinely_docs_only_pr(hook):
"""Positive control for the change above: the stricter terminator must not break the happy path.
Without this, 'never terminate on a short page' could be satisfied by never exempting anything."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(30)]), [])
assert hook.exempted() is True
def test_head_moving_mid_enumeration_withholds_the_exemption(hook, tmp_path):
"""Paging is several round-trips; a force-push between them means the assembled list belongs to
no single commit. Page 1 from head A can be combined with a short docs tail from head B while
B's code page is never read. Re-read the head and refuse if it moved."""
(tmp_path / "state" / "pr_sha_after.txt").write_text("b" * 40)
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(30)]), [])
assert hook.exempted() is False
@pytest.mark.parametrize("status", ["Renamed", "RENAMED", "bogus", None])
def test_a_rename_disguised_by_an_unknown_status_is_rejected(hook, status):
"""`renamed => previous_filename REQUIRED` was keyed on an exact lowercase string, so any other
value took the `else true` branch: a `git mv ErsatzTV/Program.cs -> docs/a.md` row whose status
is `"Renamed"` (or absent) validated fine and silently dropped its SOURCE path, reading as
docs-only. `.status` is now checked against the closed set Gitea actually emits."""
row = {"filename": "docs/a.md"}
if status is not None:
row["status"] = status
hook.set_pages([row], [])
assert hook.exempted() is False
def test_gitea_real_status_values_are_accepted(hook):
"""Positive control for the closed set. The real Gitea 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. Measured on 1.25.4; a 200-PR
corpus on 1.27.1 (2026-08-28, ersatztv#747) showed `changed`, `added`, `renamed` and `deleted`.
`copied`/`modified` did not appear there, which does not show they are never emitted."""
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
# --- allow-list ANCHOR pins (three surviving mutants) ------------------------------------------
# The `..` 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"}], [])
assert hook.exempted() is False
def test_md_must_be_a_SUFFIX_not_a_substring(hook):
"""Dropping `$` would exempt `x.md.cs`."""
hook.set_pages([{"filename": "ErsatzTV/x.md.cs", "status": "changed"}], [])
assert hook.exempted() is False
def test_an_empty_file_list_is_never_exempt(hook):
"""`[ -n "$files" ]` guards this: a PR whose enumeration yields no paths must not read as
'all of its files are docs'."""
hook.set_pages([])
assert hook.exempted() is False
def test_array_valued_status_does_not_dodge_the_allow_list(hook):
"""`index` is polymorphic: with an ARRAY argument it does SUBSEQUENCE matching, not element
equality. So `["added",...,"renamed",...] | index(["renamed"])` is 4 (truthy) while
`.status == "renamed"` is false — the row passed the allow-list AND skipped the
`previous_filename REQUIRED` clause, which is the same `git mv code -> docs/` dodge the closed
set exists to block, one type away. Fixed by requiring `.status` to be a string first."""
hook.set_pages([{"filename": "docs/a.md", "status": ["renamed"]}], [])
assert hook.exempted() is False
def test_object_valued_status_is_also_rejected(hook):
hook.set_pages([{"filename": "docs/a.md", "status": {"x": "renamed"}}], [])
assert hook.exempted() is False
# --- The `grep -q` / pipefail inversion, on the ADVISORY side (ersatztv#698) --------------------
#
# The enforced gate gained large-input regression tests while the
# hook — which carries the SAME predicate — did not. The hook's blast radius is smaller (a missing
# prompt, not a green required check), but `ci.shared-pr-file-enumeration` exists precisely because
# the copy with LESS authority is the one that quietly keeps a bug. So test both.
#
# `grep -q` exits at its first match; the producer then takes SIGPIPE (141) once the path list exceeds
# the pipe buffer, and under `set -o pipefail` a MATCH is reported as a FAILED pipeline — inverting the
# 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)]
def test_a_LARGE_pr_containing_a_code_file_is_NOT_exempt(hook):
"""The code file goes FIRST so the guard matches immediately and the producer is left with the
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"
)
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"