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
`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
212 lines
8.9 KiB
Python
212 lines
8.9 KiB
Python
"""Tests for the base-change detection in `.claude/hooks/pretooluse-merge-consent.sh` (#632).
|
|
|
|
`review-verdict/h10` is a per-sha commit status, which makes "a new commit inherits an old verdict"
|
|
impossible by construction (#622). Retargeting a PR's base reaches the same end by the opposite
|
|
route: the head sha does not move, so the status stays green, while the merge-base — and therefore
|
|
the effective diff the verdict was formed against — changes underneath it.
|
|
|
|
What is asserted here is DETECTION on the hook path only, and the tests are written to keep that
|
|
claim narrow:
|
|
|
|
* a status carries no base field of its own, so the server-side required check cannot see this at
|
|
all; a merge driven through the Gitea UI or API is unaffected. No test here implies otherwise.
|
|
* a verdict posted before #632 has no `(base: …)` in its description and must get NO opinion,
|
|
rather than denying every in-flight PR the day this lands.
|
|
|
|
Observable contract: the hook exits 0 with EMPTY stdout when it has no opinion (passthrough to
|
|
normal permissioning), and emits a JSON `permissionDecision` otherwise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
HOOK = REPO_ROOT / ".claude" / "hooks" / "pretooluse-merge-consent.sh"
|
|
|
|
SHA = "a9e3e23abf337980ca4c05854f5b1e210099d08b"
|
|
|
|
# The PR is deliberately NOT docs-only: the docs-only exemption short-circuits the whole gate, so a
|
|
# docs PR would never reach the base check and the tests would pass without exercising it.
|
|
CURL_SHIM = r"""#!/usr/bin/env python3
|
|
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])
|
|
if page == 1:
|
|
print(json.dumps([{"filename": "ErsatzTV/Program.cs", "status": "modified"}]))
|
|
else:
|
|
print("[]")
|
|
sys.exit(0)
|
|
|
|
if "/status" in url:
|
|
desc = (state / "verdict_desc").read_text()
|
|
if desc == "TRANSPORT-ERROR":
|
|
sys.exit(22)
|
|
if desc == "GARBAGE":
|
|
print('{"message":"internal error"}'); sys.exit(0)
|
|
if desc == "SCALAR-ROW":
|
|
print('{"state":"success","statuses":[1]}'); sys.exit(0)
|
|
if desc == "NONSTRING-DESC":
|
|
print(json.dumps({"state": "success", "statuses": [
|
|
{"context": "review-verdict/h10", "status": "success", "description": {"x": 1}}]}))
|
|
sys.exit(0)
|
|
rows = [] if desc == "NONE" else [
|
|
{"context": "review-verdict/h10", "status": "success", "description": desc}]
|
|
print(json.dumps({"state": "success", "statuses": rows}))
|
|
sys.exit(0)
|
|
|
|
if "/pulls/" in url:
|
|
body = {"head": {"sha": os.environ["STUB_SHA"]}, "body": "fixes #1"}
|
|
live = (state / "live_base").read_text().strip()
|
|
if live != "MISSING":
|
|
body["base"] = {"ref": live}
|
|
print(json.dumps(body))
|
|
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 / "live_base").write_text("main")
|
|
(state / "verdict_desc").write_text("Review-verdict: MERGEABLE @ a9e3e23 (base: main)")
|
|
|
|
env = dict(os.environ)
|
|
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
|
|
env["STUB_DIR"] = str(state)
|
|
env["STUB_SHA"] = SHA
|
|
env["ETV_GITEA_TOKEN"] = "stub" # noqa: S105 - deliberately fake; the real credential comes from the environment
|
|
env["ETV_GITEA_URL"] = "http://gitea.example"
|
|
env.pop("ETV_GITEA_BASICAUTH", None)
|
|
|
|
class Handle:
|
|
def set_live_base(self, ref):
|
|
(state / "live_base").write_text(ref)
|
|
|
|
def set_verdict_description(self, desc):
|
|
"""'NONE' serves a head with no review-verdict/h10 status at all."""
|
|
(state / "verdict_desc").write_text(desc)
|
|
|
|
def decision(self):
|
|
payload = {"tool_input": {"method": "merge", "owner": "timothy", "repo": "ersatztv", "pull_number": 42}}
|
|
r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), env=env, capture_output=True, text=True)
|
|
assert r.returncode == 0, r.stderr
|
|
if not r.stdout.strip():
|
|
return None
|
|
return json.loads(r.stdout)
|
|
|
|
def reason(self):
|
|
d = self.decision()
|
|
return "" if d is None else json.dumps(d)
|
|
|
|
return Handle()
|
|
|
|
|
|
def test_a_retargeted_base_denies_a_verdict_formed_against_the_old_one(hook):
|
|
hook.set_live_base("release/26.4")
|
|
reason = hook.reason()
|
|
assert "deny" in reason, "a verdict formed against a different base was allowed to stand"
|
|
assert "release/26.4" in reason and "main" in reason, (
|
|
"the deny must name both bases; a reader cannot act on 'the base changed'"
|
|
)
|
|
|
|
|
|
def test_positive_control_an_unchanged_base_does_not_trigger_the_base_deny(hook):
|
|
"""Without this, the test above could pass because the hook denies on every path — which it
|
|
very nearly does, since this PR is non-docs and the rest of the gate is unstubbed."""
|
|
reason = hook.reason()
|
|
assert "ersatztv#632" not in reason, "the base check fired on a PR whose base never moved"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"desc",
|
|
[
|
|
"Review-verdict: MERGEABLE @ a9e3e23", # posted before #632
|
|
"NONE", # no verdict status on this head at all
|
|
],
|
|
)
|
|
def test_a_verdict_with_no_recorded_base_gets_no_opinion(hook, desc):
|
|
"""Graceful adoption. Denying here would block every in-flight PR the day this lands, and the
|
|
window closes on its own: verdicts are per-head and short-lived, so every verdict posted after
|
|
#632 carries the field.
|
|
|
|
Asserting on the word "base" rather than on the issue tag: the tag-only check
|
|
would have passed for a base-specific ask or deny whose wording happened to omit it, which is
|
|
the failure mode most likely to appear when someone edits these messages.
|
|
"""
|
|
hook.set_live_base("release/26.4")
|
|
hook.set_verdict_description(desc)
|
|
assert "base" not in hook.reason(), (
|
|
"a pre-#632 verdict drew a base-related decision for a field it could not have carried"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["SCALAR-ROW", "NONSTRING-DESC"])
|
|
def test_a_malformed_status_MEMBER_asks_too(hook, failure):
|
|
"""One level below the previous fix, and it survived it.
|
|
|
|
Validating only that `.statuses` is an array left `{"statuses":[1]}` passing the guard, after
|
|
which `.context` on a number errors and a `|| true` on the extraction turned that error into an
|
|
empty description — straight back onto the graceful-adoption path, which is precisely the
|
|
outcome the guard exists to distinguish from. Same swallow-the-error shape as the bug one level
|
|
up, which is why the validation domain must match the CONSUMPTION domain rather than stopping at
|
|
the top-level type.
|
|
"""
|
|
hook.set_live_base("release/26.4")
|
|
hook.set_verdict_description(failure)
|
|
reason = hook.reason()
|
|
assert "ask" in reason and "base" in reason
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE"])
|
|
def test_an_UNREADABLE_status_response_asks_rather_than_skipping_the_check(hook, failure):
|
|
""" "Could not check" is a third outcome, not a quiet synonym for "no base recorded".
|
|
|
|
Collapsing the two makes an unreadable status response produce an empty
|
|
`recorded_base`, take the graceful-adoption path, and skip validation in silence — after
|
|
which a later successful status read could still auto-grant, emitting "merge gate: satisfied"
|
|
for a comparison that never happened. A transient Gitea hiccup is not evidence that the base is
|
|
unchanged.
|
|
"""
|
|
hook.set_live_base("release/26.4")
|
|
hook.set_verdict_description(failure)
|
|
reason = hook.reason()
|
|
assert "ask" in reason, "an unreadable status response silently skipped the base check"
|
|
assert "base" in reason, "the ask must name what could not be checked"
|
|
|
|
|
|
def test_a_pr_with_no_resolvable_base_asks(hook):
|
|
"""A null/absent `.base.ref` is also 'could not check', not 'nothing to check'."""
|
|
hook.set_live_base("MISSING")
|
|
reason = hook.reason()
|
|
assert "ask" in reason and "base" in reason
|
|
|
|
|
|
def test_the_comparator_is_the_base_REF_not_its_tip_sha():
|
|
"""The design decision this test exists to freeze. `base.sha` tracks the base branch's TIP,
|
|
which moves every time anything merges to `main` — comparing that would invalidate every open
|
|
verdict on every unrelated merge, turning a rare-event guard into a permanent merge deadlock.
|
|
A base branch that merely ADVANCES must be silent here; rebasing onto it moves the head sha,
|
|
which the per-sha binding already covers."""
|
|
assert ".base.ref" in HOOK.read_text(), "the hook must compare the base BRANCH, not its tip sha"
|
|
assert ".base.sha" not in HOOK.read_text(), "comparing base.sha deadlocks every open PR whenever main advances"
|