Round-4 review caught the miss that matters most: #643 fixed the `jq -e`-on-empty fail-open in .claude/hooks/pretooluse-merge-consent.sh, but the SAME construct sits in .gitea/workflows/review-verdict.yml — and that is the copy that runs on the CI runner, where jq is 1.6, and that feeds the branch-protection-required review-verdict/h10 status. Reproduced: `printf "" | jq -e '.statuses | type == "array"'` exits 4 on jq 1.8.2 (guard fires, correct) and 0 on jq 1.6 (guard passes). So on a transient API error `statusjson` is empty, the guard lets it through, `existing` reads "", and the job posts `pending` — or for a bot/docs-only PR an exemption `success` — over a possibly-existing human verdict. That is precisely what the comment three lines above it says must never happen. The hook version was harmless in practice because it runs on a dev Mac with jq 1.8. This one is live. Fixed identically, with a comment naming why the sibling fix missed it, and the same hardening applied to the changed-files read in the same workflow. Also from round 4: - LOW, reproduced — an ARRAY-valued `.status` dodged the closed allow-list. `index` is polymorphic: with an array argument it does SUBSEQUENCE matching, so `[...,"renamed",...] | index(["renamed"])` is truthy while `.status == "renamed"` is false — the row passed the allow-list AND skipped the `previous_filename REQUIRED` clause. The same `git mv code -> docs/` dodge the closed set exists to block, one type away. Now requires `.status` to be a string first; mutation-verified. - The record now carries all THREE jq rules rather than the one, and notes that the durable fix is pinning/preflighting the runner's jq version rather than patching constructs one at a time (tracked on #647). 200 tests pass under BOTH jq 1.8.2 and jq 1.6. Refs #647, #643, #631 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
498 lines
23 KiB
Python
498 lines
23 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()]
|
|
print(json.dumps({"head": {"sha": shas[0]}, "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"
|
|
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`: since the round-3 hardening 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]`. The statusless row this test
|
|
used to carry was incidental to what it is actually pinning.
|
|
"""
|
|
hook.set_pages([{"filename": "docs/a.md", "status": "modified"},
|
|
{"filename": "docs/b.md", "status": "added"},
|
|
{"filename": "docs/c.md", "status": "changed"}])
|
|
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"
|
|
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 first version of this test used the latter, which the allow-list
|
|
rejects on its own merits, so the test passed with the newline guard entirely removed — it
|
|
asserted the outcome without ever exercising the mechanism. That is the same
|
|
filter-hides-the-defect trap the guard itself is about."""
|
|
hook.set_pages([{"filename": "docs/ok.md", "previous_filename": "safe.md\ndocs/Program.cs",
|
|
"status": "renamed"}], [])
|
|
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 1.25.4 value for an edit is `changed`,
|
|
NOT `modified` — a closed allow-list built from the wrong vocabulary would reject every real
|
|
docs-only PR, which is a far worse failure than the hole it closes."""
|
|
hook.set_pages([{"filename": "docs/a.md", "status": "changed"},
|
|
{"filename": "docs/b.md", "status": "added"},
|
|
{"filename": "docs/c.md", "status": "deleted"}], [])
|
|
assert hook.exempted() is True
|
|
|
|
|
|
# --- allow-list ANCHOR pins (round-3 review: three surviving mutants) --------------------------
|
|
# The round-3 `..` finding was an anchor subversion, and mutating the anchors showed no test
|
|
# covered them: dropping `^` from the docs/ alternative, or `$` from `.md`, both survived.
|
|
|
|
def test_docs_must_be_a_PREFIX_not_a_substring(hook):
|
|
"""Dropping `^` would exempt `ErsatzTV/docs/Program.cs`."""
|
|
hook.set_pages([{"filename": "ErsatzTV/docs/Program.cs", "status": "changed"}], [])
|
|
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
|