From 61ec09d55c682535803321494cab7a97a9d9009f Mon Sep 17 00:00:00 2001 From: Jonathan Borduas Date: Sun, 6 Sep 2026 00:17:35 +0100 Subject: [PATCH 1/4] =?UTF-8?q?CI:=20an=20advisory=20PR-shape=20job=20?= =?UTF-8?q?=E2=80=94=20#510=20leg=201's=20other=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #510 leg 1 asks for "a check exists — CI STEP or merge-time — that refuses a PR whose diff against main removes content the PR did not add". Merge-time landed in #595. This is the CI step, so the #572 class is caught when a PR OPENS rather than when someone happens to run the guard by hand. ⛔ NOT `continue-on-error`, and that differs from the sibling job deliberately. `fleet-dependent suites` uses it and is right to — its failures mean "CI has no fleet". Here a finding means a PR would REMOVE content it did not add, and continue-on-error renders that as a warning annotation: a detector firing into a stream nobody classifies (#397). ⇒ So the job goes RED on a finding and is NOT in the required-contexts list. That is the whole of "advisory": visible, blocking nothing. ⛔ Adding it to that list is branch protection and operator-only (MERGE-AUTHORITY rule 2). Nothing here touches it — read, never written: the required set is and remains ["hermetic suites (gating)"]. ⛔ --shape-only OMITS leg 0 AND SAYS SO on every run. A runner has no holder session and cannot have one — the holder is a running pane, not a runner. A flag that made leg 0 PASS without a session would be a hole shaped exactly like the thing this tool guards, so it is omitted and named rather than defaulted true. ⇒ Shape-only establishes NOTHING about authority; that half stays merge-time. ⛔ And --shape-only with no PR is VOID, not a holder check — otherwise the flag would be a bypass: "may I merge?" answered without a session. YAML validated with a DUPLICATE-KEY-DETECTING loader, not yaml.safe_load, which silently keeps the last of a repeated key while Actions rejects the file (#582 A4). Control: a deliberately duplicated key is rejected by the same loader. 3 tests added; suite 20/20. fetch-depth: 0 because a shallow clone makes the three-dot diff read as empty — which would report every PR clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DTMf4EvaTEnDKXY47efRZZ --- .github/workflows/tools.yml | 39 +++++++++++++++++++++++++++++++++++++ tools/merge-guard.py | 25 ++++++++++++++++++++---- tools/test_merge_guard.py | 24 +++++++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml index 5dc3038..af90186 100644 --- a/.github/workflows/tools.yml +++ b/.github/workflows/tools.yml @@ -204,3 +204,42 @@ jobs: echo " or the marker moved. Both print this line; neither is a pass." fi echo "⛔ This job is continue-on-error. Its green is NOT evidence." + + # ⛔ ADVISORY, AND DELIBERATELY NOT `continue-on-error`. The sibling job above uses it, + # and for that job it is right — its failures mean "CI has no fleet". Here a finding + # means a PR would REMOVE content it did not add, and `continue-on-error` renders that + # as a warning annotation: a detector firing into a stream nobody classifies (#397). + # + # ⇒ So this job goes RED on a finding and is NOT in the required-contexts list, which + # is the whole of "advisory": visible, and blocking nothing. ⛔ Adding it to that list + # is branch protection and operator-only (docs/MERGE-AUTHORITY.md rule 2). Nothing here + # touches it. + # + # ⚠ What this CANNOT check: who may merge. A runner has no holder session and cannot + # have one — the holder is a running pane. `--shape-only` omits leg 0 and SAYS SO on + # every run; it establishes nothing about authority. That half stays merge-time. + pr-shape: + name: PR shape (advisory, NOT gating) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # ⛔ three-dot needs history; a shallow clone reads as empty + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # ⇒ #510 leg 1: "a check exists — CI STEP or merge-time — that refuses a PR whose + # diff against main removes content the PR did not add." merge-time landed first; + # this is the other half, so the #572 class is caught when the PR opens rather than + # when someone happens to run the guard by hand. + - name: revert-shape and PR hygiene + env: + GH_TOKEN: ${{ github.token }} + run: | + git fetch origin main --quiet || true + python3 tools/merge-guard.py --shape-only --pr ${{ github.event.pull_request.number }} diff --git a/tools/merge-guard.py b/tools/merge-guard.py index fd67e90..ca30b43 100644 --- a/tools/merge-guard.py +++ b/tools/merge-guard.py @@ -120,14 +120,24 @@ def pr_json(n, fields): raise Unestablished(f"gh --json {fields} was not parseable: {exc}") -def evaluate(n, session, authority_text): +def evaluate(n, session, authority_text, shape_only=False): legs = [] def leg(name, ok, detail): legs.append((name, ok, detail)) - ok, detail = holder_check(authority_text, session) - leg("0 holder == session", ok, detail) + # ⛔ --shape-only OMITS leg 0 AND SAYS SO. It exists for CI, which has no holder + # session and cannot have one: the holder is a running pane, not a runner. A flag + # that made leg 0 PASS without a session would be a hole shaped exactly like the + # thing this tool guards, so it is omitted and named rather than defaulted true. + # ⇒ Shape-only ESTABLISHES NOTHING ABOUT AUTHORITY. It answers #510 leg 1's other + # half — "a check exists, CI step OR merge-time" — and only that half. + if shape_only: + leg("0 holder == session", True, "⚠ SKIPPED — --shape-only. This run establishes " + "NOTHING about who may merge.") + else: + ok, detail = holder_check(authority_text, session) + leg("0 holder == session", ok, detail) d = pr_json(n, "baseRefName,mergeStateStatus,reviews,headRefOid,createdAt,state,statusCheckRollup") if d.get("state") != "OPEN": @@ -236,6 +246,9 @@ def main(): ap.add_argument("--session", default=os.environ.get("CLAUDE_CODE_SESSION_ID", ""), help="session id to test as (default: $CLAUDE_CODE_SESSION_ID)") ap.add_argument("--authority", default=AUTHORITY, help=f"path to {AUTHORITY}") + ap.add_argument("--shape-only", action="store_true", + help="omit the holder leg — for CI, which has no holder session. " + "⛔ Establishes nothing about authority.") ap.add_argument("--self-test", action="store_true", help="run the controls; no network") args = ap.parse_args() @@ -248,6 +261,10 @@ def main(): # condition's own command could not be satisfied by the instrument written for it. # ⇒ A holder check needs no PR: "may this session merge at all?" is answerable, and # is exactly the question those four issues pose. + if not args.prs and args.shape_only: + print("⛔ VOID — --shape-only needs a PR: without one there is no shape to check, " + "and it is not a holder check.", file=sys.stderr) + return 2 if not args.prs: try: text = Path(args.authority).read_text(encoding="utf-8") @@ -272,7 +289,7 @@ def main(): for arg in args.prs: print(f"══ PR #{arg} ══") try: - legs = evaluate(int(arg), args.session, text) + legs = evaluate(int(arg), args.session, text, args.shape_only) except (Unestablished, ValueError) as exc: print(f" ⛔ UNESTABLISHED — {exc}") print(" ⇒ BLOCK. A leg that cannot be measured is not a leg that passed.\n") diff --git a/tools/test_merge_guard.py b/tools/test_merge_guard.py index 702f1dc..ad2dc85 100644 --- a/tools/test_merge_guard.py +++ b/tools/test_merge_guard.py @@ -212,6 +212,30 @@ def test_no_pr_named_is_the_HOLDER_CHECK_not_a_refusal(self): sys.argv = old self.assertEqual(rc_void, 2, "an unreadable authority file is still VOID") + # ── --shape-only, for CI ── + + def test_shape_only_SKIPS_leg0_and_says_so(self): + """⛔ It must not read as authorization. A runner has no holder session and + cannot have one, so leg 0 is OMITTED and NAMED — never quietly passed.""" + rc, out, _ = drive(self.mod, prd(), ["1", "--shape-only"], session=OTHER) + self.assertEqual(rc, 0, "the shape legs pass; the holder leg is not evaluated") + self.assertIn("SKIPPED", out) + self.assertIn("establishes\nNOTHING about who may merge".replace("\n", " "), out) + + def test_shape_only_still_catches_a_revert(self): + """The whole point: #572's class caught at PR time, not merge time.""" + rc, out, _ = drive(self.mod, prd(), ["1", "--shape-only"], session=OTHER, + numstat="1\t900\ttools/README.md") + self.assertEqual(rc, 1) + self.assertIn("net-negative in 1 file(s)", out) + + def test_shape_only_without_a_pr_is_VOID_not_a_holder_check(self): + """⛔ The bypass that must not exist: --shape-only with no PR could otherwise + read as 'this session may merge'. It refuses.""" + rc, _, err = drive(self.mod, prd(), ["--shape-only"], session=OTHER) + self.assertEqual(rc, 2) + self.assertIn("not a holder check", err) + # ── ⛔ criterion 4: shown to FAIL ── def test_control_can_fail(self): From 378e87cf483733e9a5b655a93eded7960609d6e4 Mon Sep 17 00:00:00 2001 From: Jonathan Borduas Date: Sun, 6 Sep 2026 00:22:11 +0100 Subject: [PATCH 2/4] pr-shape: merge-guard takes PRs positionally, not --pr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job I added one commit ago failed on its own PR: python3 tools/merge-guard.py --shape-only --pr 597 merge-guard.py: error: unrecognized arguments: --pr Process completed with exit code 2 ⛔ merge-guard.py takes `prs ...` POSITIONALLY. `--pr N` is late-push.py's flag. I built the two tools hours apart and conflated their interfaces while writing the workflow — without ever running the command. ★ Two things the failure establishes, and both are worth keeping: 1 The flag-strictness works, on its author. argparse refused an unrecognised flag with exit 2 rather than ignoring it. Had it been lenient, the job would have run against NO pr and printed a clean nothing — a green advisory job establishing zero, which is the exact "looked like it worked" failure merge-guard exists to prevent. 2 The job is advisory and it went visibly red without gating anything. That is the design holding: a NOT-gating job that cannot be silent. ⇒ The fixed line now carries a comment naming both interfaces, because the next author of a workflow step is me, and I have already made this error once. Verified by running the EXACT command locally, which is what I skipped: $ python3 tools/merge-guard.py --shape-only 597 ✅ 0 holder == session ⚠ SKIPPED — --shape-only ✅ 1 base == main main ✅ 2 required gate SUCCESS ✅ 3 reviews read 0 review(s), 0 CHANGES_REQUESTED ✅ 4 three-dot diff +84 -4 (net +80) ⛔ 5 age at merge 119s since creation exit=0 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DTMf4EvaTEnDKXY47efRZZ --- .github/workflows/tools.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml index af90186..fc905ea 100644 --- a/.github/workflows/tools.yml +++ b/.github/workflows/tools.yml @@ -242,4 +242,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | git fetch origin main --quiet || true - python3 tools/merge-guard.py --shape-only --pr ${{ github.event.pull_request.number }} + # ⚠ POSITIONAL. merge-guard takes `prs ...`; late-push takes `--pr N`. The + # first version of this step wrote `--pr` here and CI refused it with exit 2 + # — the flag-strictness working, on the author of the flag-strictness. + python3 tools/merge-guard.py --shape-only ${{ github.event.pull_request.number }} From 59801272001ea5432f4db03c6fbbb1dbd55eaf24 Mon Sep 17 00:00:00 2001 From: Jonathan Borduas Date: Sun, 6 Sep 2026 00:36:35 +0100 Subject: [PATCH 3/4] =?UTF-8?q?--shape-only:=20skip=20leg=202=20as=20well?= =?UTF-8?q?=20=E2=80=94=20a=20run=20cannot=20judge=20a=20gate=20it=20conta?= =?UTF-8?q?ins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second red on this job's own PR, and this time the job was right to be red about something and wrong about what. Both check runs, PR #597, same run: hermetic suites (gating) started 23:22:19 completed 23:23:29 success PR shape (advisory) started 23:22:19 completed 23:22:25 FAILURE ⇒ pr-shape read the required gate SIX SECONDS in, 64 seconds before that gate finished. leg 2 asks "is the required gate green?" — from inside the run that CONTAINS the gate, that is self-referential. The gate was necessarily unfinished, so leg 2 was necessarily unestablished, and the job went red for a fact about its own concurrency rather than about the PR. ⇒ Same move leg 0 already makes: SKIP AND SAY SO. `--shape-only` now omits leg 2 and names why, rather than reading a null conclusion as a failure. ⚠ Why not `needs: hermetic`. It would serialise correctly, but it makes an ADVISORY job a prerequisite of nothing while doubling run latency — and the merger must re-read the gate at merge time regardless, so the runner's reading was never load-bearing. Skipping is honest; serialising is theatre. ★ The control is a PAIR on IDENTICAL input, because a skip without a known-negative is just a hole: conclusion: null + --shape-only -> exit 0, "a gate it CONTAINS" conclusion: null, no --shape-only -> exit 1, leg 2 BLOCKS Same data, opposite verdicts. That is what scopes the skip to the advisory path and proves it did not weaken the merge-time guard. Modelled as GitHub actually reports an in-progress check run — `conclusion: null`, not a falsy string — because a stub that used "" would have passed without testing it. Suite 22/22 (was 20). check-tools-index 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DTMf4EvaTEnDKXY47efRZZ --- tools/merge-guard.py | 29 +++++++++++++++++++++++------ tools/test_merge_guard.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tools/merge-guard.py b/tools/merge-guard.py index ca30b43..aafd2a6 100644 --- a/tools/merge-guard.py +++ b/tools/merge-guard.py @@ -145,13 +145,30 @@ def leg(name, ok, detail): leg("1 base == main", d["baseRefName"] == "main", d["baseRefName"]) - rollup = d.get("statusCheckRollup") or [] - req = [c for c in rollup if (c.get("name") or c.get("context") or "") == "hermetic suites (gating)"] - if not req: - leg("2 required gate", False, "UNESTABLISHED — 'hermetic suites (gating)' absent from rollup") + # ⛔ --shape-only OMITS LEG 2 TOO, and for a reason measured on this tool's own PR. + # A CI job asking "is the required gate green?" from INSIDE the run that CONTAINS + # that gate is asking a self-referential question. Measured on PR #597, both jobs + # in the same run: + # hermetic suites (gating) started 23:22:19 completed 23:23:29 success + # PR shape (advisory) started 23:22:19 completed 23:22:25 FAILURE + # ⇒ pr-shape read the gate SIX SECONDS in, 64s before the gate finished. The gate + # was necessarily unfinished, so leg 2 was necessarily unestablished, and the job + # went red for a fact about ITS OWN CONCURRENCY rather than about the PR. + # ⚠ `needs:` would serialise it, but that is the wrong fix: it makes an ADVISORY job + # a prerequisite of nothing while doubling the run's latency, and it still leaves the + # runner asserting a green gate that the merger must re-read at merge time anyway. + # ⇒ The honest move is the same one leg 0 already makes: SKIP AND SAY SO. + if shape_only: + leg("2 required gate", True, "⚠ SKIPPED — --shape-only. A run cannot establish " + "the outcome of a gate it CONTAINS. Re-read at merge.") else: - concl = req[0].get("conclusion") or req[0].get("state") or "" - leg("2 required gate", concl == "SUCCESS", concl or "UNESTABLISHED") + rollup = d.get("statusCheckRollup") or [] + req = [c for c in rollup if (c.get("name") or c.get("context") or "") == "hermetic suites (gating)"] + if not req: + leg("2 required gate", False, "UNESTABLISHED — 'hermetic suites (gating)' absent from rollup") + else: + concl = req[0].get("conclusion") or req[0].get("state") or "" + leg("2 required gate", concl == "SUCCESS", concl or "UNESTABLISHED") revs = d.get("reviews") or [] changes = [r for r in revs if r.get("state") == "CHANGES_REQUESTED"] diff --git a/tools/test_merge_guard.py b/tools/test_merge_guard.py index ad2dc85..b732762 100644 --- a/tools/test_merge_guard.py +++ b/tools/test_merge_guard.py @@ -222,6 +222,35 @@ def test_shape_only_SKIPS_leg0_and_says_so(self): self.assertIn("SKIPPED", out) self.assertIn("establishes\nNOTHING about who may merge".replace("\n", " "), out) + def test_shape_only_SKIPS_leg2_because_the_gate_is_IN_ITS_OWN_RUN(self): + """⛔ THE KNOWN-POSITIVE, and it is this tool's own PR #597. + + A pr-shape job and the gating job start in the SAME run at the same instant. + pr-shape finished at 23:22:25; the gate finished at 23:23:29. So pr-shape read + a gate with `conclusion: null` and went red for a fact about concurrency, not + about the PR. Modelled here EXACTLY as GitHub reports it: an in-progress check + run carries conclusion null, not a falsy string.""" + d = prd() + d["statusCheckRollup"] = [{"name": "hermetic suites (gating)", + "conclusion": None, "status": "IN_PROGRESS"}] + rc, out, _ = drive(self.mod, d, ["1", "--shape-only"], session=OTHER) + self.assertEqual(rc, 0, "an unfinished gate must not fail an ADVISORY shape check") + self.assertIn("a gate it CONTAINS", out) + + def test_the_SAME_pending_gate_still_BLOCKS_a_real_merge(self): + """★ THE KNOWN-NEGATIVE, without which the fix above is just a hole. + + Identical input, `--shape-only` removed. The skip must be scoped to the advisory + path ONLY: at merge time a pending gate is not a green one, and leg 2 must still + refuse. Same data, opposite verdict — that is what makes the pair evidence.""" + d = prd() + d["statusCheckRollup"] = [{"name": "hermetic suites (gating)", + "conclusion": None, "status": "IN_PROGRESS"}] + rc, out, _ = drive(self.mod, d, ["1"]) # HOLDER session, no --shape-only + self.assertEqual(rc, 1, "a pending gate BLOCKS a merge") + self.assertIn("2 required gate", out) + self.assertNotIn("a gate it CONTAINS", out) + def test_shape_only_still_catches_a_revert(self): """The whole point: #572's class caught at PR time, not merge time.""" rc, out, _ = drive(self.mod, prd(), ["1", "--shape-only"], session=OTHER, From 6ee6b0758839e9c55d362b3595d4b900b2216c7e Mon Sep 17 00:00:00 2001 From: Jonathan Borduas Date: Sun, 6 Sep 2026 00:40:46 +0100 Subject: [PATCH 4/4] --shape-only must not read the authority file it never uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by CodeRabbit reviewing this PR. Confirmed by measurement BEFORE it was believed, with a control on the other side: --shape-only 597 --authority /nonexistent/AUTH.md exit 2 ⛔ --shape-only 597 exit 0 `--shape-only` skips leg 0, so `authority_text` is referenced only at line 139 inside the branch shape-only never takes. main() read the file anyway and returned VOID when it was missing. ⛔ Why that matters, and it is not hypothetical: CI checks out THE PR'S OWN TREE. A PR that moved or deleted docs/MERGE-AUTHORITY.md would turn pr-shape red with exit 2 — for a fact about the authority record, never about the PR's shape. That is the SAME defect as leg 2 read from inside its own run, and it is how an advisory job stops being read: not by being wrong once, but by going red for reasons nobody can act on. ★ The regression is a PAIR, because an exemption without a known-negative is a bypass: --shape-only, no authority file -> reaches the shape legs, BLOCKS on the revert it was given (exit 1, not 2) real merge, no authority file -> still VOID (exit 2) The first asserts more than "not 2": it asserts the output contains "net-negative in 1 file(s)", so the test fails if the legs are reached but the diff leg is skipped. A bare assertNotEqual(rc, 2) would have passed on a tool that exited 0 having checked nothing. Suite 24/24 (was 22). ⚠ NOT claimed here: index-watch. It exits 0 "quiet" only because it is a stateful watch that short-circuits when main has not moved — a SKIP, not a verification. Recorded in the PR thread; a previous commit body in this branch claims "check-tools-index 0" and that claim is false. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DTMf4EvaTEnDKXY47efRZZ --- tools/merge-guard.py | 27 +++++++++++++++++++------- tools/test_merge_guard.py | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/tools/merge-guard.py b/tools/merge-guard.py index aafd2a6..ebabc66 100644 --- a/tools/merge-guard.py +++ b/tools/merge-guard.py @@ -294,13 +294,26 @@ def main(): if ok else "REFUSED — this session is not the holder")) return 0 if ok else 1 - try: - text = Path(args.authority).read_text(encoding="utf-8") - except OSError as exc: - print(f"⛔ VOID — cannot read {args.authority}: {exc}\n" - f" ADDABLE — run from a checkout that has it, or pass --authority.", - file=sys.stderr) - return 2 + # ⛔ --shape-only MUST NOT READ THE AUTHORITY FILE. It skips leg 0, so `text` is + # never used — but reading it made a MISSING file fatal, and CI checks out the PR's + # own tree. ⇒ A PR that MOVED OR DELETED docs/MERGE-AUTHORITY.md turned pr-shape red + # with exit 2 for a fact about the authority record, not about the PR's shape. Same + # defect as leg 2 read from inside its own run: the advisory job going red for the + # wrong reason, which is how an advisory job stops being read at all. + # ⇒ Found by CodeRabbit in review of this PR, and confirmed here by measurement + # before it was believed: + # --shape-only 597 --authority /nonexistent/AUTH.md exit 2 + # --shape-only 597 exit 0 + if args.shape_only: + text = "" + else: + try: + text = Path(args.authority).read_text(encoding="utf-8") + except OSError as exc: + print(f"⛔ VOID — cannot read {args.authority}: {exc}\n" + f" ADDABLE — run from a checkout that has it, or pass --authority.", + file=sys.stderr) + return 2 worst = 0 for arg in args.prs: diff --git a/tools/test_merge_guard.py b/tools/test_merge_guard.py index b732762..f662d39 100644 --- a/tools/test_merge_guard.py +++ b/tools/test_merge_guard.py @@ -222,6 +222,46 @@ def test_shape_only_SKIPS_leg0_and_says_so(self): self.assertIn("SKIPPED", out) self.assertIn("establishes\nNOTHING about who may merge".replace("\n", " "), out) + def test_shape_only_does_NOT_need_the_authority_file(self): + """⛔ Found by CodeRabbit reviewing this PR, confirmed by measurement first. + + --shape-only skips leg 0, so the authority text is never used — but main() read + the file anyway and returned 2 when it was missing. CI checks out the PR's OWN + tree, so a PR that MOVED docs/MERGE-AUTHORITY.md turned pr-shape red for a fact + about the authority record rather than about the PR's shape. + + ⇒ The shape legs must still be evaluated with no authority file at all.""" + self.mod.pr_json = lambda n, f: prd() + self.mod.sh = lambda a, allow_fail=False: ("1\t900\ttools/README.md" + if a[:2] == ["git", "diff"] else "") + old = sys.argv + sys.argv = ["merge-guard.py", "--shape-only", "1", + "--session", OTHER, "--authority", "/nonexistent/AUTH.md"] + out = io.StringIO() + try: + with redirect_stdout(out), redirect_stderr(io.StringIO()): + rc = self.mod.main() + finally: + sys.argv = old + self.assertNotEqual(rc, 2, "a missing authority file must not VOID a shape check") + # ★ and it must have REACHED the shape legs, not merely exited non-2 + self.assertIn("net-negative in 1 file(s)", out.getvalue()) + self.assertEqual(rc, 1, "it blocks on the REVERT, which is what it is for") + + def test_a_missing_authority_STILL_voids_a_real_merge(self): + """★ THE KNOWN-NEGATIVE. The exemption above is scoped to --shape-only ONLY. + Without it, an unreadable authority record still establishes nothing about who + may merge, and must still be VOID — otherwise the fix is a bypass.""" + old = sys.argv + sys.argv = ["merge-guard.py", "1", "--session", HOLDER, + "--authority", "/nonexistent/AUTH.md"] + try: + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + rc = self.mod.main() + finally: + sys.argv = old + self.assertEqual(rc, 2, "no authority record ⇒ VOID, for a real merge") + def test_shape_only_SKIPS_leg2_because_the_gate_is_IN_ITS_OWN_RUN(self): """⛔ THE KNOWN-POSITIVE, and it is this tool's own PR #597.