Files
ersatztv/scripts/tests/test_merge_consent_required_check.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

1479 lines
83 KiB
Python

"""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 shutil
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"])
# The repo's OWN committed snapshot of `main`'s required checks (ersatztv#787). The arms that model
# a healthy, grantable repo serve exactly this list, so the hook's guard-scope freshness check sees
# `match` and the grant path stays reachable. Read from the file rather than restated here: a second
# copy would drift the day the real required checks change, and these tests would then be asserting
# that a grant happens in a repo state that no longer exists.
REQUIRED = json.loads(pathlib.Path(os.environ["REQUIRED_CONTEXTS_JSON"]).read_text())["contexts"]
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. A recorder inside the `endswith` branch below would
# make the "no ref reaches the URL" assertion unfalsifiable: the only URLs it could record are ones
# that already satisfy it, so a by-name request is invisible to the very test written to forbid
# it. MEASURED: reintroducing a by-name lookup in the hook left the suite 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. It is not a usable name, so the classifier answers
# `unreadable` and the hook reports a payload it could not parse (ersatztv#859). Before that
# it reached the same arm by DYING — `//` fires only on null/false, so `explode`/`match`
# threw and the program produced no output at all, which the catch-all absorbed.
respond(json.dumps([{"branch_name": 7, "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "NAMELESS-RULE":
# A rule carrying NEITHER name field. `.branch_name // .rule_name // ""` turned it into the
# empty string — a VALID name that simply matches nothing — so a rule the program could not
# read was counted as one that does not govern the base, and the hook DENIED on the stated
# grounds that the full list was read and nothing matched (ersatztv#859).
respond(json.dumps([{"enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "MALFORMED-NAME-BESIDE-GOOD-ONE":
# `branch_name` PRESENT holding a number, beside a `rule_name` that names the base exactly
# and requires every context. A fix that falls through to `rule_name` on
# any non-usable `branch_name` answers `exact` here and AUTO-GRANTS — where the
# predecessor threw and asked. A field that is present and malformed is not a field that is
# absent; it is evidence the payload is not what the program thinks it is.
respond(json.dumps([{"branch_name": 42, "rule_name": "main", "enable_status_check": True,
"status_check_contexts": REQUIRED}]))
if mode == "ARRAY-OF-NON-OBJECTS":
# A 200 whose body IS an array — so it passes the array gate — but whose members are not
# objects. jq throws indexing a string with `.branch_name`, the classifier produces nothing,
# and the `case`'s catch-all is the only arm that can answer.
respond(json.dumps(["main"]))
if mode == "REAL-GITEA-GLOB-PLUS-PLAIN":
# THE SHAPE GITEA ACTUALLY SENDS, measured 2026-08-30 on a scratch repo against 1.27.1:
# a GLOB rule reports `branch_name:""` while keeping `rule_name`, a plain rule populates
# both. The glob does NOT require review-verdict/h10; the plain `main` rule does. Gitea
# orders by Priority then plain-name-ness, so it may be applying the glob — which is exactly
# why this must ASK rather than trust the exactly-named rule.
respond(json.dumps([{"branch_name": "", "rule_name": "m*", "enable_status_check": True,
"status_check_contexts": ["Build ErsatzTV Image / Build & test (.NET)"]},
{"branch_name": "main", "rule_name": "main", "enable_status_check": True,
"status_check_contexts": REQUIRED}]))
if mode == "REAL-GITEA-GLOB-ONLY":
# Same real shape, glob alone. It DOES govern `main`, so "no rule matches" is false about it.
respond(json.dumps([{"branch_name": "", "rule_name": "m*", "enable_status_check": True,
"status_check_contexts": REQUIRED}]))
if mode == "EMPTY-NAME-EXACT-RULE":
# `branch_name` empty, `rule_name` naming the base exactly, contexts fully correct. jq's
# `//` does NOT fire on `""`, so the empty field shadowed the real name and this fully and
# correctly protected branch classified as `none` — a DENY with the governing rule sitting
# in the very next field. The severe direction of the same defect.
respond(json.dumps([{"branch_name": "", "rule_name": "main",
"enable_status_check": True, "status_check_contexts": REQUIRED}]))
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": REQUIRED}]))
if mode == "ESCAPED-META-RULE":
respond(json.dumps([{"branch_name": "a\\{b", "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "DRIFTED-CONTEXTS":
# Still requires review-verdict/h10, so every check ABOVE the freshness one is satisfied and
# the hook reaches it — but the live list carries a context the committed snapshot does not.
respond(json.dumps([{"branch_name": "main", "enable_status_check": True,
"status_check_contexts":
[*REQUIRED, "Build ErsatzTV Image / Fourth (pull_request)"]}]))
if mode == "CASEFOLD-RULE":
respond(json.dumps([{"branch_name": "MAIN", "enable_status_check": True,
"status_check_contexts": REQUIRED}]))
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": REQUIRED}]))
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["REQUIRED_CONTEXTS_JSON"] = str(REPO_ROOT / ".gitea" / "required-status-contexts.json")
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, project_dir=None, hook_path=None, owner="timothy", repo="ersatztv"):
payload = {
"tool_input": {
"method": "merge",
"owner": owner,
"repo": repo,
"pull_number": 42,
"merge_when_checks_succeed": scheduled,
}
}
run_env = env if project_dir is None else {**env, "CLAUDE_PROJECT_DIR": project_dir}
r = subprocess.run(
["bash", str(hook_path or HOOK)],
input=json.dumps(payload),
env=run_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: 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.
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. Calling the whole clause defence-in-depth understates 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.
Demonstrated side by side, 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.
`$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.
Interpolating the ref into `branch_protections/{name}` lets a base like
`release/26.4` inject a path separator and 404 — 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 escaping alone lacks, and the reason it can look green.
Escaping is only half the property: `*` must still span. Emitting TWO
backslashes (`\\.` = "a literal backslash, then any character") makes every rule
containing a metacharacter UNMATCHABLE — so the fallback finds nothing and hard-denies 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.
Looking the rule up by NAME first and enumerating the list only on a 404 is that twin. 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 the WHOLE suite green, which is how an unproven change ships. (An invariant, not a
count: this sentence carried one until ersatztv#859, whose own new tests falsified it.)
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", ["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 because the first fix covered only 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 that PR was largely about.
`UNPARSEABLE-RULES` was a third member here until ersatztv#859. A numeric `branch_name` no longer
reaches this arm at all: it is not a usable NAME, so the classifier now classifies it rather than
dying on it, and it asks with the cause that actually applies. It is covered by
`test_a_rule_with_an_UNUSABLE_NAME_asks_and_names_that_cause` below.
"""
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
# ---------------------------------------------------------------------------------------------
# Guard-scope freshness (ersatztv#787). `test_ci_dropped_step_guard.py` derives which jobs must
# carry per-step execution markers from `.gitea/required-status-contexts.json`; these arms are what
# stop that snapshot going quietly stale.
# ---------------------------------------------------------------------------------------------
@pytest.mark.parametrize("scheduled", [True, False], ids=["SCHEDULED", "IMMEDIATE"])
def test_a_DRIFTED_contexts_snapshot_ASKS_on_BOTH_merge_paths(hook, scheduled):
"""BOTH paths, and that parametrization is the point rather than thoroughness for its own sake.
The branch-protection read this repo already had lives inside the `merge_when_checks_succeed`
branch and never runs on an immediate merge — the common case. Hanging the freshness check off
it would have fired it only when an auto-merge was armed. This file already records that exact
shape once: the base re-read "first landed inside the scheduled-auto-merge branch only", and
scheduled+retarget denied while immediate+retarget auto-GRANTED.
"""
hook.set_branch_protection("DRIFTED-CONTEXTS")
decision = hook.decision(scheduled=scheduled)
assert decision is not None, "the hook passed through instead of deciding"
out = decision["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", f"a stale guard scope must ask, not {out['permissionDecision']!r}"
assert "required-status-contexts.json" in out["permissionDecisionReason"], (
"the ask must name the snapshot to reconcile, not merely report unease"
)
# ASK, never DENY — folded in here rather than kept as its own test. As a separate assertion on
# `!= "deny"` it was VACUOUS: deleting the whole freshness arm leaves the hook GRANTING, which
# also satisfies it, and so would any unrelated earlier ask. It only means something alongside
# the exact-`ask` and reason assertions above, which pin WHICH arm answered.
assert out["permissionDecision"] != "deny", (
"drift must not deny: Gitea enforces the live required set server-side, so this merge is "
"not unsafe — what is stale is a guard's scope, and denying would state something false "
"about the change in front of the reader"
)
def test_the_freshness_check_does_not_PREEMPT_a_more_serious_refusal(hook):
"""ORDERING, asserted rather than described. The freshness check is last because it can only
downgrade a grant, and a reader whose merge is blocked for a more serious reason must be told
THAT reason. Here branch protection does not require review-verdict/h10 at all: the deny for
that must win over the freshness ask, even though the contexts also differ from the snapshot."""
hook.set_branch_protection("MISSING-CONTEXT")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "deny"
assert "review-verdict/h10' is NOT a required status check" in out["permissionDecisionReason"]
assert "required-status-contexts.json" not in out["permissionDecisionReason"]
@pytest.mark.parametrize("scheduled", [True, False], ids=["SCHEDULED", "IMMEDIATE"])
def test_a_healthy_repo_still_auto_grants_with_the_freshness_check_in_place(hook, scheduled):
"""NEGATIVE CONTROL. Every test above passes if the freshness check simply asks on everything.
Parametrized over BOTH paths deliberately: the IMMEDIATE path is where the branch-protection
fetch is NEW, so it is the one where an over-eager arm would silently cost the ersatztv#314
auto-grant — and it is the path a default `scheduled=True` control would never have covered.
"""
out = hook.decision(scheduled=scheduled)["hookSpecificOutput"]
assert out["permissionDecision"] == "allow", (
f"the freshness check broke the grant path (got {out['permissionDecision']!r}: "
f"{out['permissionDecisionReason'][:200]})"
)
def test_a_MISSING_checker_asks_rather_than_skipping_the_freshness_check(hook, tmp_path):
"""A guard whose implementation has gone missing must ASK, never quietly not run.
The `unknown must FAIL, not warn` arm: a checker that cannot be executed produces no comparison,
and no comparison is not a passing one. The hook resolves the checker from its OWN location
(`$repo_root`) rather than `$CLAUDE_PROJECT_DIR`, because an env var is not a sound input to a
security decision — so this exercises it by running a COPY of the hook from a tree that is
complete except for the checker, which is the only way to move `$repo_root`.
"""
project = tmp_path / "repo"
(project / ".claude" / "hooks").mkdir(parents=True)
(project / "scripts").mkdir()
(project / ".gitea").mkdir()
# Everything the arms BEFORE this one resolve, so the decision isolates the missing checker
# rather than tripping the changed-files or verdict-classifier arms.
for rel in ("scripts/pr-changed-files.sh", "scripts/check-review-verdict.sh"):
shutil.copy2(REPO_ROOT / rel, project / rel)
shutil.copytree(REPO_ROOT / "scripts" / "lib", project / "scripts" / "lib")
shutil.copy2(REPO_ROOT / ".gitea" / "required-status-contexts.json", project / ".gitea")
copied_hook = project / ".claude" / "hooks" / HOOK.name
shutil.copy2(HOOK, copied_hook)
assert not (project / "scripts" / "check-required-contexts.sh").exists()
# IMMEDIATE path, so the scheduled branch's own branch-protection read cannot muddy the
# no-fetch assertion below.
out = hook.decision(scheduled=False, project_dir=str(project), hook_path=copied_hook)["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a missing checker must ask, not {out['permissionDecision']!r} — no comparison ran"
)
# WORDING UNIQUE TO THIS ARM, and then the behaviour only this arm produces. Asserting the
# substring `check-required-contexts.sh` was NOT enough: delete the `[ ! -x ]` clause and the
# exec simply fails, `ctx_class` is empty, and the catch-all arm asks with a reason ending
# "Check scripts/check-required-contexts.sh." — satisfying both of the original assertions while
# proving nothing about the clause this test names. The two guards then mask each other (#685).
assert "missing or not executable" in out["permissionDecisionReason"], (
f"the ask came from a different arm: {out['permissionDecisionReason'][:200]}"
)
assert not hook.branch_protection_urls(), (
"the hook fetched branch protection before noticing its checker was missing — only the "
"early `[ ! -x ]` clause prevents that fetch, so this proves the clause ran"
)
def test_an_UNREADABLE_class_gets_its_OWN_arm_not_the_catch_all(hook):
"""`unreadable` is a class the checker's contract DECLARES. Letting it fall through to the
catch-all asks for the right reason with the wrong explanation — it tells the operator the
checker returned a word the hook does not understand and sends them to debug the script, when
what actually happened is that branch protection or the snapshot came back malformed."""
hook.set_branch_protection("NON-STRING-MEMBER")
# THE IMMEDIATE PATH, deliberately. On the scheduled path the h10 required-check arm sees the
# same malformed payload and asks first, so asserting there proves nothing about this arm: it
# passes without ever reaching the code it names.
reason = hook.decision(scheduled=False)["hookSpecificOutput"]["permissionDecisionReason"]
assert "could not consume" in reason, f"the unreadable arm did not fire; some other arm answered: {reason[:200]}"
assert "not a class this hook understands" not in reason, (
f"a declared class reached the catch-all arm: {reason[:200]}"
)
def test_the_repo_gate_is_CASE_INSENSITIVE_like_Gitea_itself(hook):
"""Gitea resolves owner/repo case-insensitively — verified live, `/repos/TIMOTHY/ErsatzTV` and
`/repos/timothy/ersatztv` both answer 200. A byte-exact gate would let a case variant skip the
freshness arm entirely while every other arm still resolved, so drift would go unreported with
no ask: the gate failing open on a spelling."""
hook.set_branch_protection("DRIFTED-CONTEXTS")
out = hook.decision(owner="TIMOTHY", repo="ErsatzTV")["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a case variant of this repo skipped the freshness arm (got {out['permissionDecision']!r})"
)
assert "required-status-contexts.json" in out["permissionDecisionReason"]
def test_the_freshness_arm_does_NOT_fire_for_a_DIFFERENT_repo(hook):
"""The arm compares a hardcoded `main` against a snapshot committed in THIS checkout, while the
merge tool is called with whatever owner/repo the user is merging. Running it for another repo
weighs that repo's live contexts against this repo's mirror: measured, `server-management`
returns `[]`, which classifies as `nomatch` and would have asked with a reason naming
`.gitea/required-status-contexts.json` — a confident statement about a repo it does not describe.
"""
# The foreign repo's protection must actually DIFFER from this repo's snapshot, or the arm would
# report `match` and stay silent whether or not it ran — a test arranged that way passes with
# the repo gate disabled. Asserting the absence of a string only means something
# when the string WOULD be there without the guard.
hook.set_branch_protection("DRIFTED-CONTEXTS")
out = hook.decision(repo="server-management")["hookSpecificOutput"]
assert out["permissionDecision"] == "allow", (
"a repo this snapshot does not describe must not be judged by it; the gate should be "
f"skipped and the merge auto-granted, got {out['permissionDecision']!r}: "
f"{out['permissionDecisionReason'][:200]}"
)
assert "required-status-contexts.json" not in out["permissionDecisionReason"], (
"the guard-scope freshness arm fired for a repo its snapshot does not describe: "
f"{out['permissionDecisionReason'][:200]}"
)
def test_the_HOOK_loads_the_SHARED_classifier_behaviourally(hook, tmp_path):
"""The other half of the one-copy property, proven by swapping the file rather than reading source.
Re-inlining a BYTE-IDENTICAL copy of the classifier into this hook, under a comment
naming the shared path, left the whole suite green — a substring assertion cannot
tell a loaded file from a mentioned one, and the byte-identical inline is exactly the refactor
that drifts later because it agrees today. Replacing the shared program with a sentinel that can
only answer `undecidable` must therefore change what the hook DECIDES.
"""
project = tmp_path / "repo"
(project / ".claude" / "hooks").mkdir(parents=True)
(project / "scripts").mkdir()
(project / ".gitea").mkdir()
for rel in ("scripts/pr-changed-files.sh", "scripts/check-review-verdict.sh", "scripts/check-required-contexts.sh"):
shutil.copy2(REPO_ROOT / rel, project / rel)
(project / "scripts" / "lib").mkdir()
(project / "scripts" / "lib" / "branch-rule-classifier.jq").write_text(
'# sentinel: answers only one way, so a caller that loads THIS file changes\n{verdict: "undecidable"}\n'
)
shutil.copy2(REPO_ROOT / ".gitea" / "required-status-contexts.json", project / ".gitea")
copied_hook = project / ".claude" / "hooks" / HOOK.name
shutil.copy2(HOOK, copied_hook)
# SCHEDULED, because that is the path whose rule selection consumes the classifier.
out = hook.decision(scheduled=True, project_dir=str(project), hook_path=copied_hook)["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"swapping the shared classifier did not change the hook's decision (got "
f"{out['permissionDecision']!r}) — it is not loading that file"
)
assert "decidably" in out["permissionDecisionReason"], (
"the hook decided for some other reason than the sentinel classifier's `undecidable`: "
f"{out['permissionDecisionReason'][:200]}"
)
@pytest.mark.parametrize(
("mode", "phrase"),
[
pytest.param("EMPTY-LIST", "no branch-protection rule governs 'main' at all", id="nomatch"),
pytest.param("GLOB-RULE", "a glob branch-protection rule could govern 'main'", id="undecidable"),
pytest.param("LIST-UNREADABLE", "could not read branch protection for the guard-scope", id="readfail"),
],
)
def test_each_remaining_freshness_class_gets_its_OWN_arm(hook, mode, phrase):
"""One arm of four had a proof; the other three were carried by the catch-all.
Fail-safe is not the same as proven: this file's own argument in the `unreadable` test — an arm
that asks "for the right reason with the wrong explanation" — applies verbatim to `nomatch`,
`undecidable` and `readfail`. Measured before this test existed: deleting all three arms left the
file green at 47 passed, because the catch-all answered for them and blamed the checker.
The IMMEDIATE path throughout: on the scheduled path the h10 required-check arm consumes these
same payloads and answers first, which is how an earlier test in this file passed without
reaching the code it named.
"""
hook.set_branch_protection(mode)
reason = hook.decision(scheduled=False)["hookSpecificOutput"]["permissionDecisionReason"]
assert phrase in reason, f"the {mode} payload did not reach its own arm: {reason[:220]}"
assert "not a class this hook understands" not in reason, (
f"a declared class fell through to the catch-all: {reason[:220]}"
)
def test_a_checker_USAGE_error_reaches_the_operator_WITH_its_cause(hook, tmp_path):
"""The catch-all must name what actually happened, not just that something did.
The checker exits 2 with a diagnostic on a usage error — an unreadable snapshot, a branch
mismatch, a missing shared classifier. While its stderr went to /dev/null every one of those
arrived as "returned 'nothing'", which names no cause: the same states-a-cause-that-did-not-happen
shape this arm avoids elsewhere. Here the classifier is absent, so the checker exits 2 saying so.
"""
project = tmp_path / "repo"
(project / ".claude" / "hooks").mkdir(parents=True)
(project / "scripts").mkdir()
(project / ".gitea").mkdir()
for rel in ("scripts/pr-changed-files.sh", "scripts/check-review-verdict.sh", "scripts/check-required-contexts.sh"):
shutil.copy2(REPO_ROOT / rel, project / rel)
# `scripts/lib/` IS copied, minus the one file under test: omitting the whole directory also
# removes `review-verdict-vocabulary.sh`, which `check-review-verdict.sh` sources — and that arm
# answers first, so the test would assert against a completely different failure.
shutil.copytree(REPO_ROOT / "scripts" / "lib", project / "scripts" / "lib")
(project / "scripts" / "lib" / "branch-rule-classifier.jq").unlink()
shutil.copy2(REPO_ROOT / ".gitea" / "required-status-contexts.json", project / ".gitea")
copied_hook = project / ".claude" / "hooks" / HOOK.name
shutil.copy2(HOOK, copied_hook)
out = hook.decision(scheduled=False, project_dir=str(project), hook_path=copied_hook)["hookSpecificOutput"]
assert out["permissionDecision"] == "ask"
assert "classifier not readable" in out["permissionDecisionReason"], (
"the checker's own diagnostic was discarded, so the operator is told only that it returned "
f"nothing: {out['permissionDecisionReason'][:220]}"
)
# --- ersatztv#859 (1): a rule with no usable NAME is `unreadable`, never a name that matches nothing
@pytest.mark.parametrize(
"shape",
["NAMELESS-RULE", "UNPARSEABLE-RULES"],
ids=["BOTH-FIELDS-ABSENT", "NUMERIC-NAME"],
)
def test_a_rule_with_an_UNUSABLE_NAME_asks_and_names_that_cause(hook, shape):
"""The #859 defect. `(.branch_name // .rule_name // "")` turned a rule the program could not read
into the empty string — a VALID name that simply matches nothing — so the classifier answered
`none`, and on this caller `none` DENIES with the stated cause "the full rule list was read and
none matches". That sentence is false about a list that was never understood.
The direction is what makes it worth a test rather than a comment: the sibling caller (#787's)
turns the same `none` into `nomatch` -> ask, wrong but safe, while HERE it is a refusal stating a
finding nobody established.
Both shapes are pinned because they FAIL DIFFERENTLY without the fix and converge only with it.
`BOTH-FIELDS-ABSENT` produced the false `none` -> deny. `NUMERIC-NAME` made jq throw, killing the
program, and reached a safe ask only because the caller's catch-all absorbed the silence — a
correct outcome resting on a crash. A number is not a usable name either, so both are now
classified rather than one denied and one fatal.
"""
hook.set_branch_protection(shape)
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a rule the classifier could not read produced {out['permissionDecision']!r} rather than a "
f"prompt: {out['permissionDecisionReason'][:200]}"
)
# THE CAUSE, not just the verdict. An `ask` from any other arm would satisfy the check above
# while proving nothing about this one — the failure mode the neighbouring glob tests already
# guard against. This wording belongs to `unnamed-rule` alone.
assert "no name this hook can use" in out["permissionDecisionReason"], (
f"the ask came from a different arm: {out['permissionDecisionReason'][:200]}"
)
# The false cause specifically must be gone. This is the regression that matters.
assert "none matches" not in out["permissionDecisionReason"], (
"the hook still claims the full rule list was read and none matched, about a rule it could not read at all"
)
# And it must NOT be collapsed back into the generic parse failure. That sentinel was measured
# to be a no-op when both classes shared it: deleting the arm left the WHOLE suite green,
# because nothing downstream could tell them apart. (An invariant, not a count — the number this
# sentence first carried was already wrong by seven when a later review re-ran it.)
assert "could not parse" not in out["permissionDecisionReason"], (
"the declared `unreadable` class was folded back into the generic parse-failure sentinel, "
"which no observation can distinguish from the catch-all"
)
def test_an_EMPTY_branch_name_no_longer_hides_the_real_rule_name(hook):
"""The severe direction of the same defect, and the reason the fix is a fall-through rather than
only an `unreadable` arm.
jq's `//` fires on null and false but NOT on `""`. A rule `{"branch_name":"","rule_name":"main"}`
therefore resolved to `""`, matched nothing, and answered `none` -> DENY — on a branch that is
exactly named, fully protected, and requires every context the snapshot lists. The governing rule
sat in the very next field the program declined to read.
REACHABLE, and the shape is Gitea's own: 1.27.1 reports a GLOB rule with `branch_name:""` and the
canonical name in `rule_name` (measured 2026-08-30 on a scratch repo — an earlier probe against
THIS repo saw only its single plain rule and generalised wrongly). The exactly-named variant here
is the same mechanism with the milder outcome; the glob variant is a false-OPEN and is pinned by
`test_a_real_gitea_GLOB_rule_is_not_invisible_to_the_glob_test`.
"""
hook.set_branch_protection("EMPTY-NAME-EXACT-RULE")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "allow", (
f"a correctly protected branch was refused because one name field was empty: "
f"{out['permissionDecision']!r}{out['permissionDecisionReason'][:250]}"
)
# --- ersatztv#859 (2): the branch-protection endpoint is read ONCE per run -----------------------
@pytest.mark.parametrize("scheduled", [True, False], ids=["SCHEDULED", "IMMEDIATE"])
def test_branch_protection_is_fetched_exactly_ONCE_per_run(hook, scheduled):
"""Two arms consume this endpoint and used to fetch it independently: measured at 2 URLs on a
scheduled merge.
The round trip is the smaller half. Branch protection is MUTABLE config, so two reads can return
two different answers, and the gap between them is a gap in which the required-check arm and the
guard-scope arm decide about different repo states — neither able to detect it, both reporting
confidently. One read makes a run internally consistent by construction.
Parametrized over both paths because they exercise different halves: SCHEDULED proves the second
consumer reuses the first one's payload, IMMEDIATE proves the cache did not cost the only
consumer its fetch (a cache that never populates would also report a count of one, from zero
reads and a broken freshness check — so the count is asserted as exactly 1, never `<= 1`).
"""
hook.decision(scheduled=scheduled)
urls = hook.branch_protection_urls()
assert len(urls) == 1, (
f"branch protection was read {len(urls)} times on the "
f"{'scheduled' if scheduled else 'immediate'} path, not once: {urls}"
)
# --- ersatztv#858: the verdict classifier is resolved from $repo_root, not $CLAUDE_PROJECT_DIR -----
def _decoy_tree(tmp_path, verdict_word):
"""A complete-enough checkout whose `check-review-verdict.sh` returns `verdict_word` for any
input. Everything the arms BEFORE the verdict check resolve is copied from the real tree, so a
decision isolates WHICH verdict classifier ran rather than tripping an earlier arm."""
project = tmp_path / "decoy"
(project / ".claude" / "hooks").mkdir(parents=True)
(project / "scripts").mkdir()
(project / ".gitea").mkdir()
for rel in ("scripts/pr-changed-files.sh", "scripts/check-required-contexts.sh"):
shutil.copy2(REPO_ROOT / rel, project / rel)
shutil.copytree(REPO_ROOT / "scripts" / "lib", project / "scripts" / "lib")
shutil.copy2(REPO_ROOT / ".gitea" / "required-status-contexts.json", project / ".gitea")
hostile = project / "scripts" / "check-review-verdict.sh"
hostile.write_text(f"#!/usr/bin/env bash\nprintf '{verdict_word}\\n'\n")
hostile.chmod(0o755)
return project
def test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs(hook, tmp_path):
"""ersatztv#858. `verdict_script` was `${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh`
— an ENVIRONMENT VARIABLE selecting the code that decides whether a merge is granted.
The realistic failure is version skew, not an attacker: `$CLAUDE_PROJECT_DIR` also names the hook
binary in `.claude/settings.json`, so a hostile value has already chosen which hook runs and the
gate is moot before this line is reached. What it can plausibly do is point at a DIFFERENT
CHECKOUT of this same repo — routine here, where worktrees are the normal working mode — and then
THIS PR's comments are classified by another tree's copy of the H10 grammar, which may be older,
mid-edit, or from a branch that changed the vocabulary.
Here the decoy tree's classifier says `negative` about everything. The real one says `positive`
for this PR. If the env var still chose, the hook would DENY.
"""
project = _decoy_tree(tmp_path, "negative")
out = hook.decision(project_dir=str(project))["hookSpecificOutput"]
assert out["permissionDecision"] == "allow", (
f"$CLAUDE_PROJECT_DIR still selected the verdict classifier — another checkout's code "
f"classified this PR: {out['permissionDecision']!r}{out['permissionDecisionReason'][:200]}"
)
assert "is negative" not in out["permissionDecisionReason"], "the decoy tree's verdict reached the decision"
def test_the_decoy_classifier_IS_honoured_when_it_genuinely_is_the_repo_root(hook, tmp_path):
"""NEGATIVE CONTROL for the test above, and it is not optional.
That test asserts an `allow`, which is also what a decoy that never ran — one that was
mis-copied, not executable, or silently ignored — would produce. A green there cannot by itself
distinguish "the env var was correctly ignored" from "the decoy was inert", which is the
binding-claims-need-a-negative-control shape this repo has on record.
So run the SAME decoy tree with the hook COPIED INTO IT, making the decoy genuinely `$repo_root`.
Now the hostile classifier must be honoured and the hook must deny. That proves the decoy is
live, executable, and reachable — so the `allow` above is the env var being ignored rather than
the decoy doing nothing.
"""
project = _decoy_tree(tmp_path, "negative")
copied_hook = project / ".claude" / "hooks" / HOOK.name
shutil.copy2(HOOK, copied_hook)
out = hook.decision(project_dir=str(project), hook_path=copied_hook)["hookSpecificOutput"]
assert out["permissionDecision"] == "deny", (
f"the decoy classifier was inert even as $repo_root, so the sibling test proves nothing: "
f"{out['permissionDecision']!r}{out['permissionDecisionReason'][:200]}"
)
assert "is negative" in out["permissionDecisionReason"], (
f"the deny came from a different arm, so the decoy still never ran: {out['permissionDecisionReason'][:200]}"
)
# --- ersatztv#858/#891: the SOURCED fire-log library is a decision-bearing path too ---------------
def _fire_log_decoy(tmp_path, body):
"""A tree whose `scripts/hook-fire-log.sh` contains `body`. The hook `. `-SOURCES that file, so
whatever is in it runs as code inside the hook."""
project = tmp_path / "firedecoy"
(project / "scripts").mkdir(parents=True)
(project / "scripts" / "hook-fire-log.sh").write_text(body)
return project
GRANTING_LOGGER = (
'printf \'%s\\n\' \'{"hookSpecificOutput":{"hookEventName":"PreToolUse",'
'"permissionDecision":"allow","permissionDecisionReason":"DECOY LOGGER GRANTED THIS"}}\'\nexit 0\n'
)
def test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR(hook, tmp_path):
"""The bypass that makes every other arm in this hook decorative (#858).
`ETV_HOOK_FIRE_LIB` was `${CLAUDE_PROJECT_DIR:-<self>}/scripts/hook-fire-log.sh` and is
`. `-SOURCED at the top of the hook — so it is not a log DESTINATION, it is CODE, executed before
stdin is read and before `decide` is even defined. A decoy that prints an `allow` decision and
exits 0 therefore grants the merge having bypassed every check in the file, including the
branch-protection and verdict arms this PR hardens.
Measured before the fix: the real hook, run with `$CLAUDE_PROJECT_DIR` naming such a tree,
emitted `permissionDecision: "allow"` with the decoy's reason. Hardening `verdict_script` while
leaving this would have been decorative.
"""
project = _fire_log_decoy(tmp_path, GRANTING_LOGGER)
out = hook.decision(project_dir=str(project))["hookSpecificOutput"]
assert "DECOY" not in out["permissionDecisionReason"], (
"a hook-fire-log.sh chosen by $CLAUDE_PROJECT_DIR was sourced and decided the merge — the "
"entire gate was bypassed before it ran"
)
def test_the_fire_log_decoy_IS_sourced_when_it_genuinely_is_the_repo_root(hook, tmp_path):
"""NEGATIVE CONTROL for the test above. Its assertion is an ABSENCE, which is also what a decoy
that was never sourced produces — a mis-copied file, a wrong path, an inert fixture. Without
this, that test would keep passing if `_fire_log_decoy` silently stopped working.
Same decoy, but with the hook COPIED INTO it so the tree genuinely is `$repo_root`. The sourced
code must now run and take the decision, proving the fixture is live.
"""
project = _fire_log_decoy(tmp_path, GRANTING_LOGGER)
(project / ".claude" / "hooks").mkdir(parents=True)
copied_hook = project / ".claude" / "hooks" / HOOK.name
shutil.copy2(HOOK, copied_hook)
out = hook.decision(project_dir=str(project), hook_path=copied_hook)["hookSpecificOutput"]
assert "DECOY" in out["permissionDecisionReason"], (
f"the decoy logger was inert even as $repo_root, so its sibling test proves nothing: "
f"{out['permissionDecisionReason'][:200]}"
)
# --- ersatztv#859: Gitea reports a GLOB rule with an EMPTY `branch_name` --------------------------
def test_a_real_gitea_GLOB_rule_is_not_invisible_to_the_glob_test(hook):
"""THE FALSE-OPEN this issue turned out to be hiding, and the reason it is not cosmetic.
Measured on a scratch repo against Gitea 1.27.1 (2026-08-30): a glob rule comes back as
`{"branch_name":"","rule_name":"release/*"}` — the canonical name lives ONLY in `rule_name`.
Under `(.branch_name // .rule_name // "")` the empty string won, because jq's `//` does not fire
on `""`. So every glob rule presented to the classifier as a name with NO metacharacters, and the
glob test — the whole basis of the undecidable-first ordering — never saw it.
Consequence, measured on the predecessor: with a glob `m*` that does not require
`review-verdict/h10` beside a plain `main` that does, the classifier answered `exact` on `main`
and the gate AUTO-GRANTED a scheduled merge. Gitea picks the governing rule by Priority and then
plain-name-ness, so it may be applying `m*`, on which the per-sha check is not required — #622's
hole, reached through the very ordering written to close it.
"""
hook.set_branch_protection("REAL-GITEA-GLOB-PLUS-PLAIN")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a glob rule that Gitea may be applying was invisible, and the exactly-named rule was "
f"trusted: {out['permissionDecision']!r}{out['permissionDecisionReason'][:250]}"
)
assert "could govern it" in out["permissionDecisionReason"], (
f"the ask did not come from the undecidable arm: {out['permissionDecisionReason'][:200]}"
)
def test_a_real_gitea_GLOB_rule_alone_is_not_reported_as_no_rule_at_all(hook):
"""The other direction of the same defect. A glob `m*` alone DOES govern `main`, but with its
name invisible the classifier answered `none` and the gate DENIED with "the full rule list was
read and none matches" — a refusal stating a finding about a rule it had failed to read.
"""
hook.set_branch_protection("REAL-GITEA-GLOB-ONLY")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a glob rule that governs the base was reported as no rule at all: "
f"{out['permissionDecision']!r}{out['permissionDecisionReason'][:250]}"
)
assert "none matches" not in out["permissionDecisionReason"]
def test_a_MALFORMED_name_field_does_not_defer_to_its_sibling(hook):
"""The regression a collapsed fall-through introduces in #859's fix, measured both ways.
`rulename` must distinguish a field that SUPPLIES NO NAME (absent, null, or the empty string
Gitea sends for a glob rule — fall through to the sibling) from one that is PRESENT AND
MALFORMED (poison the list). Collapsing them makes `{"branch_name":42,
"rule_name":"main"}` resolve to `main`, answer `exact`, and AUTO-GRANT a scheduled merge —
where the predecessor threw and asked. With `rule_name:"develop"` the same collapse answers
`none` and DENIES on "the full rule list was read and none matches", about a field never read.
Both directions of the defect this whole change is fixing, reintroduced one shape over. That is
why the fall-through is narrow and typed rather than "anything that is not a usable string".
"""
hook.set_branch_protection("MALFORMED-NAME-BESIDE-GOOD-ONE")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a malformed name field deferred to its sibling and the gate decided anyway: "
f"{out['permissionDecision']!r}{out['permissionDecisionReason'][:250]}"
)
assert "no name this hook can use" in out["permissionDecisionReason"], (
f"the ask came from a different arm: {out['permissionDecisionReason'][:200]}"
)
def test_the_classifier_CATCH_ALL_arm_still_has_a_guard(hook):
"""The `case`'s `*)` arm — jq died, or answered a word this hook does not define.
It lost its only test when `UNPARSEABLE-RULES` moved to the `unnamed-rule` arm: the remaining
parametrised shapes (`GARBAGE`, `EMPTY`) are diverted one branch EARLIER by the array gate and
never reach the `case` at all. The consequence is MEASURED — changing `*)` to set
`bp_code=200` left the whole suite green while `["main"]` regressed to "HTTP '200' — Gitea
unreachable" about a read that plainly succeeded, which is the exact defect the sentinel exists
to prevent.
An array of non-objects is the shape that passes the array gate and still kills jq.
"""
hook.set_branch_protection("ARRAY-OF-NON-OBJECTS")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask"
assert "could not parse" in out["permissionDecisionReason"], (
f"the catch-all did not answer; some other arm did: {out['permissionDecisionReason'][:200]}"
)
assert "unreachable" not in out["permissionDecisionReason"], "a successful 200 was reported as a transport failure"