review-verdict/h10 Awaiting review verdict for 07e1e8c
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Review verdict / Set review-verdict status (pull_request) Successful in 10s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
PR Gates / Docs update reminder (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Third BLOCKED verdict, third legitimate finding. The per-row guard validated the
DESTINATION only, so `{"filename":"docs/x.md","status":"renamed"}` with no
`previous_filename` passed while its source silently vanished — precisely the
path-hiding that collecting both sides exists to prevent. A rename row must now
carry a non-empty `previous_filename`.
Required for rename rows ONLY. Demanding it globally would reject every ordinary
modified/added row and make the gate refuse all exemptions — which every
"withholds the exemption" test would happily pass through, so that direction gets
its own positive control. Predicate checked against ten shapes before adopting;
mutation-verified in both directions (dropping the clause fails 2 tests, applying
it globally fails 3 including the controls).
Also fixes a wart this PR introduced. `review-verdict/h10` is itself folded into
the COMBINED status, so a PR awaiting its verdict reports combined 'pending' and
the hook's condition (a) reported it as a CI problem — sending a reader to build
logs when the missing thing is the review, and exiting before the H10 branch that
would have said so. The message now names the outstanding contexts, and says
plainly when the verdict is the only one left.
Scope boundary, stated in the record: real Gitea populates `previous_filename` on
renames and returns well-formed pages. Everything past this point defends against
shapes with no evidence of existing, so the guard's claim stays "any page we
cannot fully classify withholds the exemption" rather than growing to cover
unobserved responses.
Decisions-Edit: yes
233 lines
9.9 KiB
Python
233 lines
9.9 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:
|
|
print(json.dumps({"head": {"sha": os.environ["STUB_SHA"]}, "body": "no linked issue here"}))
|
|
sys.exit(0)
|
|
|
|
print("{}")
|
|
'''
|
|
|
|
|
|
def _rows(paths):
|
|
return [{"filename": p, "status": "modified"} for p in paths]
|
|
|
|
|
|
@pytest.fixture
|
|
def hook(tmp_path):
|
|
bindir = tmp_path / "bin"; bindir.mkdir()
|
|
shim = bindir / "curl"; shim.write_text(CURL_SHIM); shim.chmod(0o755)
|
|
state = tmp_path / "state"; state.mkdir()
|
|
|
|
env = dict(os.environ)
|
|
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
|
|
env["STUB_DIR"] = str(state)
|
|
env["STUB_SHA"] = SHA
|
|
env["ETV_GITEA_TOKEN"] = "stub"
|
|
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.
|
|
"""
|
|
hook.set_pages([{"filename": "docs/a.md", "status": "modified"},
|
|
{"filename": "docs/b.md", "status": "added"},
|
|
{"filename": "docs/c.md"}])
|
|
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
|