From 96f6377f1d2484cb0f51b18f98abdc5f5a7dfe9b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:21:01 +0500 Subject: [PATCH 1/5] ci(step7): the merge gate becomes a check, not a script in a drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #357 built a gate that refuses the wrong merge. Nothing ran it. A weapon in a safe is not a guard at the door, so this is the wiring: a workflow that runs it against the commit a merge would produce, under two names a ruleset can require. Wiring it exposed two things the gate could not answer on its own. **Applicability.** A required check runs on every pull request in the repository. `mergegate.py` refuses a tree with no T0 in it — correctly, as a question about a contract that must be there — so as a required check today it would refuse every pull request, including this one. The wrapper settles it first: no frozen T0 at the merge commit means no merge can make the contract reachable, the check passes and says so. The moment a tree carries T0 every predicate applies in full. **Co-change.** The workflow that runs on a pull request is the one on that pull request. A branch could otherwise carry the freeze and a weakened gate together and be judged by the gate it brought with it. So the gate's own files — `mergegate.py`, this wrapper, the workflow — may not change in the same merge that introduces or changes the frozen contract. Repairing the gate on its own stays ordinary work; that distinction is a control, not a promise. The applicability decision lives in the tool that owns `T0_PATH`, never a second copy in YAML, and a control fails if the workflow ever grows one. Another fails if the contexts the workflow declares stop matching the ones a ruleset is told to require — a required check nothing reports waits forever and reads as protection. Exit codes are the step-7 three: 0 proceed or not applicable, 1 refused, 2 the question could not be asked. 11 wiring controls, 0 failed; the 15 gate controls unchanged. Both suites also run green on Linux, which is where the job will run. Against real commits: this branch's head is not applicable, the full chain is allowed, #354 alone is refused on three predicates. This is half the wiring. Until a ruleset requires these two contexts, the job reports and nothing is prevented. #354 stays a draft until it does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- .github/workflows/p022-merge-gate.yml | 70 +++++++ scripts/step7/mergegate_ci.py | 125 ++++++++++++ tests/test_step7_mergegate_ci.py | 277 ++++++++++++++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 .github/workflows/p022-merge-gate.yml create mode 100644 scripts/step7/mergegate_ci.py create mode 100644 tests/test_step7_mergegate_ci.py diff --git a/.github/workflows/p022-merge-gate.yml b/.github/workflows/p022-merge-gate.yml new file mode 100644 index 00000000..db536d73 --- /dev/null +++ b/.github/workflows/p022-merge-gate.yml @@ -0,0 +1,70 @@ +name: P-022 merge gate + +# The merge order for the #263 preregistration, as a check rather than an +# agreement. `mergegate.py` is run against the commit a merge would PRODUCE — +# for a pull_request event that is `github.sha`, the merge ref, not the branch +# head — because a PR number proves someone pressed a button and says nothing +# about what the merged tree contains. +# +# Two jobs, and the gate needs the controls: a gate whose own controls are +# failing must not be the thing that says a merge is safe. A failed controls job +# leaves the gate job unreported, which a required check reads as unsatisfied. +# +# This workflow is one half of the wiring. The other half is a ruleset requiring +# these two checks; without it the job reports and nothing is prevented. The +# names below are the contexts that ruleset must name: +# +# P-022 merge gate controls +# P-022 merge gate +# +# Least privilege as elsewhere in this repository: read-only, and every +# third-party `uses:` pinned to a commit SHA with its version in a comment. + +permissions: + contents: read + +on: + pull_request: + workflow_dispatch: + +jobs: + controls: + name: P-022 merge gate controls + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: the gate's own controls (fixtures ship the real tools and mutate them) + run: python tests/test_step7_mergegate.py + - name: the wiring's controls (applicability and co-change) + run: python tests/test_step7_mergegate_ci.py + + gate: + name: P-022 merge gate + needs: controls + runs-on: ubuntu-latest + steps: + # Full history: the co-change rule compares the gate's own files against + # the base commit, which a shallow clone would not carry. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: the gate, against the commit this merge would produce + env: + # Read through the environment rather than interpolated into the + # script body, so nothing from the event can be read as shell. + MERGE_COMMIT: ${{ github.sha }} + BASE_COMMIT: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [ -n "${BASE_COMMIT}" ]; then + python scripts/step7/mergegate_ci.py --repo . \ + --commit "${MERGE_COMMIT}" --base "${BASE_COMMIT}" + else + python scripts/step7/mergegate_ci.py --repo . --commit "${MERGE_COMMIT}" + fi diff --git a/scripts/step7/mergegate_ci.py b/scripts/step7/mergegate_ci.py new file mode 100644 index 00000000..4709ab13 --- /dev/null +++ b/scripts/step7/mergegate_ci.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""P-022 — the merge gate as a status check. + +`mergegate.py` answers one question: may the frozen contract become reachable +from this tree? It answers it about a tree that *has* a frozen contract. A +required status check runs on every pull request in the repository, including +the ones that have nothing to do with P-022, so two rules have to be settled +before that gate can be wired to a branch protection rule. + +**Applicability.** If the frozen T0 does not exist at the merge commit, no merge +can make it reachable and there is nothing to protect. The check passes and says +so. This is not a way around the gate: the moment a tree carries T0, every +predicate applies in full. + +**Co-change.** A required check that a pull request can rewrite is decoration. +The workflow that runs on a pull request is the one *on that pull request*, so a +branch may carry both the freeze and a weakened gate and be judged by the gate +it brought with it. So: **the gate's own files may not change in the same merge +that introduces or changes the frozen contract.** Repairing the gate is still +ordinary work; doing it in the same breath as the freeze is not. + +Neither rule is enforcement isolation. A repository administrator can turn the +protection off, and this script cannot see that. What it removes is the quiet +path — a weakened gate arriving as part of the change it was meant to judge. + +Exit codes: 0 the merge may proceed (or the gate does not apply), 1 it is +refused, 2 the question could not be asked. + + python scripts/step7/mergegate_ci.py --repo . --commit \ + --base + +`--base` is what the co-change rule compares against. Without it the rule cannot +run, and the output says that rather than implying it passed. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import mergegate as mg # noqa: E402 + +# The gate's own surface: the predicates, this wrapper, and the workflow that +# runs them. A change to any of these changes what "the gate passed" means. +GATE_FILES = ("scripts/step7/mergegate.py", + "scripts/step7/mergegate_ci.py", + ".github/workflows/p022-merge-gate.yml") + + +def blob_sha(repo: Path, commit: str, path: str) -> str | None: + """The blob a path resolves to, or None when the path is not there.""" + proc = subprocess.run(["git", "-C", str(repo), "rev-parse", f"{commit}:{path}"], + capture_output=True, text=True, check=False) + return proc.stdout.strip() if proc.returncode == 0 else None + + +def applies(repo: Path, commit: str) -> bool: + return blob_sha(repo, commit, mg.T0_PATH) is not None + + +def co_change(repo: Path, commit: str, base: str) -> list[str]: + """Which gate files this merge changes, if it also touches the contract. + + Empty when the contract is untouched: repairing the gate is ordinary work. + """ + t0_now, t0_base = blob_sha(repo, commit, mg.T0_PATH), blob_sha(repo, base, mg.T0_PATH) + if t0_now == t0_base: + return [] + return [path for path in GATE_FILES + if blob_sha(repo, commit, path) != blob_sha(repo, base, path)] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path(".")) + parser.add_argument("--commit", required=True, + help="the commit a merge would produce, not the branch head") + parser.add_argument("--base", help="the base commit the co-change rule compares against") + args = parser.parse_args(argv) + + proc = subprocess.run(["git", "-C", str(args.repo), "rev-parse", f"{args.commit}^{{commit}}"], + capture_output=True, text=True, check=False) + if proc.returncode != 0: + print(f"merge gate: MISUSE — {args.commit} is not a commit in {args.repo}") + return 2 + + if not applies(args.repo, args.commit): + print(f"merge gate: not applicable — {mg.T0_PATH} does not exist at {args.commit[:12]}, " + "so no merge here can make the frozen contract reachable. Every predicate " + "applies the moment a tree carries it.") + return 0 + + if args.base is None: + print("merge gate: the co-change rule did not run — no --base was given, so this pass " + "cannot say whether the gate itself changed alongside the contract") + else: + changed = co_change(args.repo, args.commit, args.base) + if changed: + print("merge gate: REFUSED — this merge changes the frozen contract and the gate " + f"that judges it in one act: {', '.join(changed)}. Repair the gate in its own " + "merge; a guard that arrives with what it guards is not a guard.") + return 1 + print("ok [gate_unchanged] the contract moves and the gate does not; this merge is " + "judged by the gate already on the base") + + results = mg.gate(args.repo, args.commit) + for row in results: + mark = "ok " if row["result"] == "pass" else "FAIL" + print(f"{mark} [{row['check']}] {row['detail']}") + bad = [r for r in results if r["result"] != "pass"] + print() + if bad: + print(f"merge gate: REFUSED, {len(bad)} predicate(s) unsatisfied — the frozen contract " + "must not become reachable from this tree") + return 1 + print("merge gate: allowed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py new file mode 100644 index 00000000..b85720b2 --- /dev/null +++ b/tests/test_step7_mergegate_ci.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""P-022 — controls on the wiring that makes the merge gate a status check. + + ci-not-applicable a tree without the frozen T0 passes, and says why + ci-applies-in-full a tree with it is judged by every predicate + ci-refusal-survives a refusal from the gate is still a refusal here + ci-co-change-refused the gate may not change in the merge that brings the contract + ci-gate-repair-allowed repairing the gate on its own is ordinary work + ci-no-base-is-stated without a base the co-change rule says it did not run + ci-misuse-is-two a commit that does not exist is misuse, not a verdict + ci-gate-files-exist every file the co-change rule watches is really there + ci-workflow-names the workflow names the contexts a ruleset must require + ci-workflow-derives-t0 the workflow hard-codes no path the tools already own + control-inventory this list and the executed set are the same set + +The applicability rule is the one that makes a required check possible at all: +without it the gate refuses every pull request in the repository, including the +one that adds it. The co-change rule is the one that makes it worth requiring: +a pull request runs its own copy of the workflow, so a branch could otherwise +carry the freeze and a weakened gate together and be judged by the gate it +brought with it. + +Run: python tests/test_step7_mergegate_ci.py +""" + +from __future__ import annotations + +import contextlib +import io +import sys +import tempfile +from collections.abc import Callable +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts" / "step7")) +sys.path.insert(0, str(ROOT / "tests")) + +import mergegate_ci as ci # noqa: E402 +import test_step7_mergegate as mgt # noqa: E402 + +WORKFLOW = ROOT / ".github/workflows/p022-merge-gate.yml" +CONTEXTS = ("P-022 merge gate controls", "P-022 merge gate") + +_FAILURES: list[tuple[str, str]] = [] +_PASSES: list[str] = [] + + +def fail(check: str, detail: str) -> None: + _FAILURES.append((check, detail)) + print(f"FAIL[{check}]: {detail}") + + +def ok(check: str, detail: str = "") -> None: + _PASSES.append(check) + print(f"ok[{check}]: {detail}" if detail else f"ok[{check}]") + + +def guarded(check: str, control: Callable[[], None]) -> None: + try: + control() + except Exception as exc: + fail(check, f"the control raised {type(exc).__name__}: {exc}") + + +def run(*args: str) -> tuple[int, str]: + """The wrapper's exit code and everything it said, captured. A green run that + prints REFUSED teaches readers to skim past refusals.""" + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = ci.main(list(args)) + return rc, out.getvalue() + + +def wired(tmp: Path, name: str, **kwargs: object) -> tuple[Path, str, str]: + """A repository, the commit that carries T0, and the commit before it. + + `world()` builds its tree in two commits: the first carries the instrument + and the tools, the second adds T0 and the bindings. That first commit is + exactly the shape of a base branch that does not yet carry the contract. + """ + repo, head = mgt.world(tmp, name=name, **kwargs) + return repo, head, head + "^" + + +def control_not_applicable() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, _, before = wired(Path(raw), "na") + rc, said = run("--repo", str(repo), "--commit", before) + if rc != 0: + fail("ci-not-applicable", f"a tree with no frozen contract was refused: {said[:160]}") + return + if "not applicable" not in said: + fail("ci-not-applicable", f"it passed without saying why: {said[:160]}") + return + ok("ci-not-applicable", + "with no T0 in the tree no merge can make the frozen contract reachable, so the check " + "passes and names the reason — otherwise it would refuse every pull request in the repo") + + +def control_applies_in_full() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, before = wired(Path(raw), "full") + rc, said = run("--repo", str(repo), "--commit", head, "--base", before) + if rc != 0: + fail("ci-applies-in-full", f"a satisfying tree was refused: {said[:200]}") + return + missing = [c for c in ("t0_frozen_and_authorized", "instrument_matches_t0", + "steps_4_5_6_rebound", "step7_machinery_enforces") + if c not in said] + if missing: + fail("ci-applies-in-full", f"the wrapper did not report {missing}") + return + ok("ci-applies-in-full", + "the moment a tree carries T0 every predicate runs and is reported by name") + + +def control_refusal_survives() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, before = wired(Path(raw), "stale", rebound=False) + rc, said = run("--repo", str(repo), "--commit", head, "--base", before) + if rc != 1 or "REFUSED" not in said: + fail("ci-refusal-survives", + f"a stale binding came back as rc={rc}: {said[:200]}") + return + ok("ci-refusal-survives", + "the wrapper adds rules, it does not soften the ones underneath: a stale binding is " + "still exit 1") + + +def control_co_change_refused() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, before = wired(Path(raw), "cochange") + # the branch brings the contract and its own copy of the gate at once + attacked = mgt.commit_tree(repo, { + ci.GATE_FILES[0]: "# a gate that says yes\n", + ci.GATE_FILES[2]: "name: P-022 merge gate\n"}) + rc, said = run("--repo", str(repo), "--commit", attacked, "--base", before) + if rc != 1: + fail("ci-co-change-refused", + "a merge carrying both the freeze and a rewritten gate was allowed") + return + if ci.GATE_FILES[0] not in said: + fail("ci-co-change-refused", f"refused without naming the changed file: {said[:200]}") + return + _ = head + ok("ci-co-change-refused", + "a guard that arrives together with what it guards is not a guard; the refusal names " + "every gate file the merge rewrites") + + +def control_gate_repair_allowed() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, _ = wired(Path(raw), "repair") + # the contract is already in the base and does not move; only the gate does + repaired = mgt.commit_tree(repo, {ci.GATE_FILES[0]: "# a repaired gate\n"}) + rc, said = run("--repo", str(repo), "--commit", repaired, "--base", head) + if rc != 0: + fail("ci-gate-repair-allowed", + f"an ordinary repair of the gate was refused: {said[:200]}") + return + if "gate_unchanged" not in said: + fail("ci-gate-repair-allowed", f"it passed without saying so: {said[:200]}") + return + ok("ci-gate-repair-allowed", + "when the contract does not move, changing the gate is ordinary work and stays " + "possible — the rule is about co-change, not about freezing the gate forever") + + +def control_no_base_is_stated() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, head, _ = wired(Path(raw), "nobase") + rc, said = run("--repo", str(repo), "--commit", head) + if rc != 0: + fail("ci-no-base-is-stated", f"a satisfying tree was refused: {said[:200]}") + return + if "did not run" not in said: + fail("ci-no-base-is-stated", + f"a pass with no base did not say the co-change rule was skipped: {said[:200]}") + return + ok("ci-no-base-is-stated", + "a manual run cannot pose as a full one: without a base the wrapper says the co-change " + "rule did not run rather than implying it held") + + +def control_misuse_is_two() -> None: + with tempfile.TemporaryDirectory() as raw: + repo, _, _ = wired(Path(raw), "misuse") + rc, said = run("--repo", str(repo), "--commit", "f" * 40) + if rc != 2: + fail("ci-misuse-is-two", f"a commit that does not exist returned {rc}: {said[:160]}") + return + ok("ci-misuse-is-two", + "a question that could not be asked is exit 2, never a verdict — the same three codes " + "the step-7 tools already use") + + +def control_gate_files_exist() -> None: + missing = [p for p in ci.GATE_FILES if not (ROOT / p).exists()] + if missing: + fail("ci-gate-files-exist", + f"the co-change rule watches {missing}, which are not in this repository; a " + "renamed file would be watched by nobody") + return + ok("ci-gate-files-exist", + f"all {len(ci.GATE_FILES)} watched files exist: {', '.join(ci.GATE_FILES)}") + + +def control_workflow_names() -> None: + if not WORKFLOW.exists(): + fail("ci-workflow-names", f"{WORKFLOW.name} does not exist") + return + text = WORKFLOW.read_text(encoding="utf-8") + absent = [c for c in CONTEXTS if f"name: {c}" not in text] + if absent: + fail("ci-workflow-names", + f"the workflow declares no job named {absent}; a ruleset requiring that context " + "would wait forever on a check nothing reports") + return + if "mergegate_ci.py" not in text: + fail("ci-workflow-names", "the workflow does not run the wrapper") + return + ok("ci-workflow-names", + f"the workflow declares exactly the contexts a ruleset must require: {', '.join(CONTEXTS)}") + + +def control_workflow_derives_t0() -> None: + """The workflow must not carry a second copy of a path the tools own.""" + import mergegate as mg + text = WORKFLOW.read_text(encoding="utf-8") + if mg.T0_PATH in text: + fail("ci-workflow-derives-t0", + f"the workflow hard-codes {mg.T0_PATH}; applicability would then be decided in two " + "places that can drift apart") + return + ok("ci-workflow-derives-t0", + "applicability is decided once, in the tool that owns T0_PATH; the workflow only runs it") + + +def control_inventory() -> None: + import re + listed = set(re.findall("^ (ci-[a-z0-9-]+|control-inventory) +[^ ]", __doc__ or "", + re.MULTILINE)) + executed = {name for name, _ in CONTROLS} + if listed != executed: + fail("control-inventory", + f"listed but not executed: {sorted(listed - executed)}; executed but not listed: " + f"{sorted(executed - listed)}") + return + ok("control-inventory", f"{len(executed)} controls listed, {len(executed)} executed") + + +CONTROLS: list[tuple[str, Callable[[], None]]] = [ + ("ci-not-applicable", control_not_applicable), + ("ci-applies-in-full", control_applies_in_full), + ("ci-refusal-survives", control_refusal_survives), + ("ci-co-change-refused", control_co_change_refused), + ("ci-gate-repair-allowed", control_gate_repair_allowed), + ("ci-no-base-is-stated", control_no_base_is_stated), + ("ci-misuse-is-two", control_misuse_is_two), + ("ci-gate-files-exist", control_gate_files_exist), + ("ci-workflow-names", control_workflow_names), + ("ci-workflow-derives-t0", control_workflow_derives_t0), + ("control-inventory", control_inventory), +] + + +def main() -> int: + for name, control in CONTROLS: + guarded(name, control) + print() + print(f"merge gate wiring controls: {len(_PASSES)} passed, {len(_FAILURES)} failed") + return 1 if _FAILURES else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 61cfea975ec7a0406526838f5e610ece9addaa89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:32:03 +0500 Subject: [PATCH 2/5] style(step7): drop a noqa the linter does not need ruff allows a sys.path insertion before an import, so the E402 suppression was covering nothing and RUF100 said so. Caught by running the CI-pinned ruff (0.15.8) rather than the one that happened to be installed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/mergegate_ci.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/step7/mergegate_ci.py b/scripts/step7/mergegate_ci.py index 4709ab13..022dc9c4 100644 --- a/scripts/step7/mergegate_ci.py +++ b/scripts/step7/mergegate_ci.py @@ -42,7 +42,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) -import mergegate as mg # noqa: E402 +import mergegate as mg # The gate's own surface: the predicates, this wrapper, and the workflow that # runs them. A change to any of these changes what "the gate passed" means. From 1e488fdf87902f2bf169b0c7507bc1773fdb41cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:54:41 +0500 Subject: [PATCH 3/5] fix(step7): an unreachable base is a question unasked, not a refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If `--base` named a commit the clone does not carry, `blob_sha` returned None for every gate file on that side, the co-change rule read all of them as rewritten, and the merge was REFUSED — for a fact about the checkout's depth rather than about the merge. The workflow sets fetch-depth 0, so this would not have fired today; it would have fired the first time someone ran the wrapper by hand, or the first time a shallow clone was used to save a minute, and the refusal would have been about the gate being rewritten. A false refusal teaches people that the gate is noise, which is how a required check dies. The base is now validated like the merge commit, and an unreachable one is exit 2 with a message that names the reason. A control builds exactly that case and requires 2, refusing to accept 1. 12 wiring controls, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/mergegate_ci.py | 17 ++++++++++++++--- tests/test_step7_mergegate_ci.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/step7/mergegate_ci.py b/scripts/step7/mergegate_ci.py index 022dc9c4..1d839e9c 100644 --- a/scripts/step7/mergegate_ci.py +++ b/scripts/step7/mergegate_ci.py @@ -62,6 +62,11 @@ def applies(repo: Path, commit: str) -> bool: return blob_sha(repo, commit, mg.T0_PATH) is not None +def is_commit(repo: Path, rev: str) -> bool: + return subprocess.run(["git", "-C", str(repo), "rev-parse", f"{rev}^{{commit}}"], + capture_output=True, check=False).returncode == 0 + + def co_change(repo: Path, commit: str, base: str) -> list[str]: """Which gate files this merge changes, if it also touches the contract. @@ -82,11 +87,17 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--base", help="the base commit the co-change rule compares against") args = parser.parse_args(argv) - proc = subprocess.run(["git", "-C", str(args.repo), "rev-parse", f"{args.commit}^{{commit}}"], - capture_output=True, text=True, check=False) - if proc.returncode != 0: + if not is_commit(args.repo, args.commit): print(f"merge gate: MISUSE — {args.commit} is not a commit in {args.repo}") return 2 + # A base that is not in the clone would make every gate file look changed, + # and the co-change rule would refuse for a reason that is about the checkout + # rather than about the merge. A question that cannot be asked is not a no. + if args.base is not None and not is_commit(args.repo, args.base): + print(f"merge gate: MISUSE — the base {args.base} is not a commit in {args.repo}; " + "the co-change rule cannot be evaluated, and a shallow checkout must not be " + "reported as a rewritten gate") + return 2 if not applies(args.repo, args.commit): print(f"merge gate: not applicable — {mg.T0_PATH} does not exist at {args.commit[:12]}, " diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py index b85720b2..e3b42f03 100644 --- a/tests/test_step7_mergegate_ci.py +++ b/tests/test_step7_mergegate_ci.py @@ -8,6 +8,7 @@ ci-gate-repair-allowed repairing the gate on its own is ordinary work ci-no-base-is-stated without a base the co-change rule says it did not run ci-misuse-is-two a commit that does not exist is misuse, not a verdict + ci-absent-base-is-two nor may an unavailable base be reported as a rewritten gate ci-gate-files-exist every file the co-change rule watches is really there ci-workflow-names the workflow names the contexts a ruleset must require ci-workflow-derives-t0 the workflow hard-codes no path the tools already own @@ -195,6 +196,24 @@ def control_misuse_is_two() -> None: "the step-7 tools already use") +def control_absent_base_is_two() -> None: + """A shallow checkout must not be able to manufacture a refusal.""" + with tempfile.TemporaryDirectory() as raw: + repo, head, _ = wired(Path(raw), "absentbase") + rc, said = run("--repo", str(repo), "--commit", head, "--base", "e" * 40) + if rc == 1: + fail("ci-absent-base-is-two", + "a base that is not in the clone was reported as a rewritten gate; every gate " + "file looks changed against a base nobody has") + return + if rc != 2: + fail("ci-absent-base-is-two", f"an unavailable base returned {rc}: {said[:160]}") + return + ok("ci-absent-base-is-two", + "an unreachable base is exit 2 and says so; the co-change rule refuses to answer rather " + "than answering with the checkout's depth") + + def control_gate_files_exist() -> None: missing = [p for p in ci.GATE_FILES if not (ROOT / p).exists()] if missing: @@ -258,6 +277,7 @@ def control_inventory() -> None: ("ci-gate-repair-allowed", control_gate_repair_allowed), ("ci-no-base-is-stated", control_no_base_is_stated), ("ci-misuse-is-two", control_misuse_is_two), + ("ci-absent-base-is-two", control_absent_base_is_two), ("ci-gate-files-exist", control_gate_files_exist), ("ci-workflow-names", control_workflow_names), ("ci-workflow-derives-t0", control_workflow_derives_t0), From a49ef6cdd89cc4e77fe98e59a2e30d5ae77ee855 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 09:18:07 +0500 Subject: [PATCH 4/5] fix(tests): the wiring suite exposes run(), so the runner can run it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/run_tests.py imports every test_*.py and calls `run()` on it. The new suite called its entry point `main()`, so the runner reported it as a module with no run() and ended the whole test job — after my own controls had printed 15 green lines, which is exactly the shape of a failure that looks like someone else's. Found by reading CI rather than by running the file directly: `python tests/test_step7_mergegate_ci.py` passes either way, and that is precisely why the local run said nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- tests/test_step7_mergegate_ci.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py index e3b42f03..5359366b 100644 --- a/tests/test_step7_mergegate_ci.py +++ b/tests/test_step7_mergegate_ci.py @@ -285,7 +285,9 @@ def control_inventory() -> None: ] -def main() -> int: +def run() -> int: + # `run`, not `main`: tests/run_tests.py imports every test_*.py and calls + # run() on it, and a module without one is a suite that reports nothing. for name, control in CONTROLS: guarded(name, control) print() @@ -294,4 +296,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + sys.exit(run()) From 715bbe7facd28cffbf8d559c565bc3664d6d5bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 09:24:35 +0500 Subject: [PATCH 5/5] fix(tests): rename the helper, not the entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gave this module a run() for tests/run_tests.py to call — onto a name a local helper already had. The helper won, the entry point was shadowed, and eight controls started invoking themselves recursively instead of the wrapper. Caught immediately by ruff (F811) and by the suite dropping to 4 passed, 8 failed, which is what a repository's own checks are for. The helper is `invoke` now and says in its docstring why it is not `run`. 12 wiring controls, 0 failed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- tests/test_step7_mergegate_ci.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/test_step7_mergegate_ci.py b/tests/test_step7_mergegate_ci.py index 5359366b..60de8d8b 100644 --- a/tests/test_step7_mergegate_ci.py +++ b/tests/test_step7_mergegate_ci.py @@ -64,9 +64,12 @@ def guarded(check: str, control: Callable[[], None]) -> None: fail(check, f"the control raised {type(exc).__name__}: {exc}") -def run(*args: str) -> tuple[int, str]: +def invoke(*args: str) -> tuple[int, str]: """The wrapper's exit code and everything it said, captured. A green run that - prints REFUSED teaches readers to skim past refusals.""" + prints REFUSED teaches readers to skim past refusals. + + Not named `run`: that name belongs to this module's own entry point, which + tests/run_tests.py calls.""" out = io.StringIO() with contextlib.redirect_stdout(out): rc = ci.main(list(args)) @@ -87,7 +90,7 @@ def wired(tmp: Path, name: str, **kwargs: object) -> tuple[Path, str, str]: def control_not_applicable() -> None: with tempfile.TemporaryDirectory() as raw: repo, _, before = wired(Path(raw), "na") - rc, said = run("--repo", str(repo), "--commit", before) + rc, said = invoke("--repo", str(repo), "--commit", before) if rc != 0: fail("ci-not-applicable", f"a tree with no frozen contract was refused: {said[:160]}") return @@ -102,7 +105,7 @@ def control_not_applicable() -> None: def control_applies_in_full() -> None: with tempfile.TemporaryDirectory() as raw: repo, head, before = wired(Path(raw), "full") - rc, said = run("--repo", str(repo), "--commit", head, "--base", before) + rc, said = invoke("--repo", str(repo), "--commit", head, "--base", before) if rc != 0: fail("ci-applies-in-full", f"a satisfying tree was refused: {said[:200]}") return @@ -119,7 +122,7 @@ def control_applies_in_full() -> None: def control_refusal_survives() -> None: with tempfile.TemporaryDirectory() as raw: repo, head, before = wired(Path(raw), "stale", rebound=False) - rc, said = run("--repo", str(repo), "--commit", head, "--base", before) + rc, said = invoke("--repo", str(repo), "--commit", head, "--base", before) if rc != 1 or "REFUSED" not in said: fail("ci-refusal-survives", f"a stale binding came back as rc={rc}: {said[:200]}") @@ -136,7 +139,7 @@ def control_co_change_refused() -> None: attacked = mgt.commit_tree(repo, { ci.GATE_FILES[0]: "# a gate that says yes\n", ci.GATE_FILES[2]: "name: P-022 merge gate\n"}) - rc, said = run("--repo", str(repo), "--commit", attacked, "--base", before) + rc, said = invoke("--repo", str(repo), "--commit", attacked, "--base", before) if rc != 1: fail("ci-co-change-refused", "a merge carrying both the freeze and a rewritten gate was allowed") @@ -155,7 +158,7 @@ def control_gate_repair_allowed() -> None: repo, head, _ = wired(Path(raw), "repair") # the contract is already in the base and does not move; only the gate does repaired = mgt.commit_tree(repo, {ci.GATE_FILES[0]: "# a repaired gate\n"}) - rc, said = run("--repo", str(repo), "--commit", repaired, "--base", head) + rc, said = invoke("--repo", str(repo), "--commit", repaired, "--base", head) if rc != 0: fail("ci-gate-repair-allowed", f"an ordinary repair of the gate was refused: {said[:200]}") @@ -171,7 +174,7 @@ def control_gate_repair_allowed() -> None: def control_no_base_is_stated() -> None: with tempfile.TemporaryDirectory() as raw: repo, head, _ = wired(Path(raw), "nobase") - rc, said = run("--repo", str(repo), "--commit", head) + rc, said = invoke("--repo", str(repo), "--commit", head) if rc != 0: fail("ci-no-base-is-stated", f"a satisfying tree was refused: {said[:200]}") return @@ -187,7 +190,7 @@ def control_no_base_is_stated() -> None: def control_misuse_is_two() -> None: with tempfile.TemporaryDirectory() as raw: repo, _, _ = wired(Path(raw), "misuse") - rc, said = run("--repo", str(repo), "--commit", "f" * 40) + rc, said = invoke("--repo", str(repo), "--commit", "f" * 40) if rc != 2: fail("ci-misuse-is-two", f"a commit that does not exist returned {rc}: {said[:160]}") return @@ -200,7 +203,7 @@ def control_absent_base_is_two() -> None: """A shallow checkout must not be able to manufacture a refusal.""" with tempfile.TemporaryDirectory() as raw: repo, head, _ = wired(Path(raw), "absentbase") - rc, said = run("--repo", str(repo), "--commit", head, "--base", "e" * 40) + rc, said = invoke("--repo", str(repo), "--commit", head, "--base", "e" * 40) if rc == 1: fail("ci-absent-base-is-two", "a base that is not in the clone was reported as a rewritten gate; every gate "