diff --git a/.github/workflows/closing-target-claim-guard.yml b/.github/workflows/closing-target-claim-guard.yml new file mode 100644 index 0000000000..487d19aa63 --- /dev/null +++ b/.github/workflows/closing-target-claim-guard.yml @@ -0,0 +1,140 @@ +# A pull request may close a card only while that card's own thread carries a +# `Claim:` naming this pull request's head branch. +# +# Every agent here shares ONE GitHub identity, so the assignee field is a +# presence bit and the `Claim:` comment is the identity record. Two guards +# already run at pull-request time — the Duplicate Fix Guard ("no other open PR +# may claim the same issue") and the Single-Claim Path Guard — and neither asks +# the question that decides ownership. That question WAS already asked, by row +# H46 of the half-state sweep, and asked AFTER THE FACT as a report-only patrol +# input: the cloud-repo incident (one card implemented 29 minutes apart by two +# seats, the second with no claim) and an objectstack card implemented twice in +# one morning were both visible to that predicate and stopped by nothing. A +# control that detects a defect it cannot prevent is a TIMING defect in an +# existing control, not an absent one — so this workflow runs the same +# predicate at the moment the merge can still be stopped. +# +# The rule, the imported predicates, the declined-number policy, the +# branch-rename remedy and the exit contract all live in +# `scripts/check-closing-target-claim.mjs`; that header is authoritative and +# this file is the invocation. +# +# Sibling shape, deliberately copied rather than reinvented: the Part-of +# Closing-Keyword Guard and the Single-Claim Path Guard are this repo's other +# PR-scoped blocking checks, and this takes their job shape, their runtime pin +# and their `pull_request` trigger set. +name: Closing-Target Claim Guard + +# THREE things are load-bearing here. +# +# `edited`: one of the two remedies is to drop the closing keyword and write +# `Part of #N` instead. The body is the frozen half of this check's input, so +# that remedy needs a fresh event rather than a re-run — exactly as in the two +# body-scoped sibling guards. +# +# `synchronize`: the head BRANCH is half the question, and a force-free re-push +# onto a differently-named branch arrives as a new pull request, but a push that +# changes what the PR contains must be re-judged on the commit that changed it. +# +# `merge_group`: and this is where this gate deliberately DIVERGES from both +# siblings, whose headers say they take no queue leg because a merge-queue event +# carries no pull request. True — and here it does not matter, because the queue +# REF NAMES its pull request (`gh-readonly-queue//pr--`). The +# script reads that number with the governed queue guard's own shipped parser +# and then reads the same PR body and the same live comment threads, so the +# queue build judges the SAME pull request by the SAME rule. That is what makes +# the card's "the check holds in the queue too" a wiring fact rather than an +# assumption. +# +# ⚠️ In a MULTI-PR group the queue ref names only the LAST pull request; the +# script header states that limit. Every member of the group passed the +# `pull_request` leg to be armed at all. +on: + pull_request: + types: [opened, edited, reopened, synchronize] + merge_group: + +# ⛔ NO `paths:` filter, on either leg. A skipped job counts as SUCCESS in branch +# protection, so a path filter would hand a green check to the very PR the +# filter mis-scoped — and this gate's input is a PR BODY, which no path filter +# can see at all. (`merge_group` has no paths support either, so a filter would +# also make the two legs disagree about what they cover.) + +# Read-only, and that is the whole grant. This gate reports; it never closes a +# PR, comments, edits a body, or writes a label. +# +# `contents: read` is not optional padding: naming a `permissions:` block at all +# sets every scope NOT listed to `none`, and this job checks the repo out to get +# at the script. `issues: read` is what the card comment threads need — the +# claim record is an ISSUE comment, and the pull-requests scope does not reach +# it. `pull-requests: read` is what the merge_group leg needs to read the body +# of the pull request its queue ref names. +permissions: + contents: read + issues: read + pull-requests: read + +concurrency: + group: closing-target-claim-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + closing-target-claim: + # ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it + # is duplicated in `CHECK_CONTEXT_NAME` in the script — deliberately, and + # pinned in both directions: the script's `--self-test` reads THIS FILE and + # fails if the two ever disagree. Renaming a job silently detaches a + # required context. + # + # ⛔ Making it a required context is NOT this PR's step. Branch protection + # takes a `REQUIRED_CONTEXTS` row PLUS the Settings → Rulesets entry, in one + # sitting, and the settings half is the maintainer's. Either half alone is + # an outage. What the merge_group leg above buys today is that the question + # is asked on the queue build; what it buys the day someone does make this + # required is that it cannot deadlock the queue, which a workflow with no + # queue leg always does. + name: The card this PR closes must claim this branch + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + # Pinned to the same major and spelling as every other setup-node in this + # repo, for the measured reason the sibling guards record: a setup-node + # major whose package-manager-cache default is on reads `packageManager` + # out of package.json and shells out to pnpm to find its store, killing + # the job in the SETUP step with "Unable to locate executable file: pnpm" + # — before the script runs, and naming a tool the workflow source never + # mentions. This job installs no package manager on purpose: the script is + # dependency-free and imports two sibling modules. + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + # Everything reaches the script through `env:`, never through `${{ }}` + # inside the `run:` line. A PR body is arbitrary author-controlled text, + # and an expression interpolated into a shell line is substituted before + # bash ever sees it; through `env:` it is inert data. The script's + # self-test pins this spelling. + # + # On the merge_group leg every `github.event.pull_request.*` expression + # renders EMPTY and `MERGE_GROUP_HEAD_REF` carries the queue ref, which is + # what selects the leg inside the script. On the pull_request leg the + # reverse holds. Neither leg needs its own step. + # + # `PR_HEAD_REF` is not decoration: the head branch IS the question this + # gate asks, so a blank one exits NOT MEASURED rather than green. + # + # The token is required, not optional: the claim record is a comment on + # another issue, which cannot be answered from the event payload. + - name: A PR may close only a card that claims its branch + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/check-closing-target-claim.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5dc469c435..21b8a8350f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1030,6 +1030,33 @@ jobs: - name: Single-claim path guard self-test run: pnpm check:single-claim-paths + # Closing-target claim guard self-test (#15845). Same split as the step + # above and for the same reason: the guard is a PR-scoped blocking check + # in its own workflow, because its question is about the COMMENT THREAD of + # the card a PR closes and needs a pull request plus an API read that this + # job has neither of. What runs HERE is the half that needs no PR — the + # verdict layer, the exit-code contract, the collection policy over a fake + # transport, and the wiring. + # + # Three things make this step worth its second. (a) The ACCEPTANCE PAIR: + # a correct claim is green and a claim naming a DIFFERENT branch is red, + # because a probe exercised only on its red half has not been shown to + # reach the card at all. (b) The NEGATIVE CONTROLS: a PR that closes + # nothing and a PR carrying only `Part of` make zero API calls and emit no + # annotation, so the gate cannot become a tax on correct PRs. (c) The COST + # assertions are measured over a fake transport with a call log, so + # "bounded by the closing-keyword count" is a test rather than a claim in + # a header. + # + # It also pins the WIRING (the guard workflow still invokes the script, + # still carries the check-run name the script pins, still subscribes to + # `edited` and `synchronize`, still takes the `merge_group` leg without + # which the queue claim would be prose, and still passes the head ref and + # the token), so unwiring the gate reddens here rather than going quiet. + # Dependency-free, imports two sibling modules, reads one file; ~0.1s. + - name: Closing-target claim guard self-test + run: pnpm check:closing-target-claim + # PM half-state sweeper self-test (#8528). `scripts/pm/check-half-states.mjs` # carried a 79-case --self-test — the H1..H7 predicates, the seat-sticker # parser, the transport classifier and its measured container classes — diff --git a/.github/workflows/partof-closing-keyword-guard.yml b/.github/workflows/partof-closing-keyword-guard.yml index f9a414f13d..1be4a95bd2 100644 --- a/.github/workflows/partof-closing-keyword-guard.yml +++ b/.github/workflows/partof-closing-keyword-guard.yml @@ -13,8 +13,8 @@ # reuses; that header is authoritative, this file is the invocation. # # Sibling shape, deliberately copied rather than reinvented: the Duplicate Fix -# Guard is this repo's other PR-body-scoped blocking check, and it takes the -# same trigger set for the same measured reason. +# Guard is one of this repo's other PR-body-scoped blocking checks, and it takes +# the same trigger set for the same measured reason. name: Part-of Closing-Keyword Guard # `edited` is load-bearing, not decoration. The body is this check's whole diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 89098a07db..e8be6ea494 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -14,10 +14,15 @@ name: PR Automation # run it produced is the first measured pass in that round where the gate read # the line. # -# Sibling shape, deliberately copied rather than reinvented: this repo's two -# other PR-body-scoped blocking checks -- `duplicate-fix-guard.yml` and -# `partof-closing-keyword-guard.yml` -- already take `edited` and already carry -# this argument. +# Sibling shape, deliberately copied rather than reinvented: this repo's other +# PR-body-scoped blocking checks -- `duplicate-fix-guard.yml`, +# `partof-closing-keyword-guard.yml` and `closing-target-claim-guard.yml` -- +# already take `edited` and already carry this argument. +# +# ⚠️ The third of those joined AFTER the measurement below was taken, which is +# why that paragraph still says "the two `edited`-subscribed workflows above": +# it is a dated reading over the two that existed on 2026-09-08, and it is left +# exactly as measured rather than re-derived from today's set. # # COST, measured rather than waved at (2026-09-01..09-08, this repo's own run # counts through `GET /actions/workflows//runs?event=pull_request`): diff --git a/.github/workflows/single-claim-path-guard.yml b/.github/workflows/single-claim-path-guard.yml index 3d2c77e831..61c1bfc5a8 100644 --- a/.github/workflows/single-claim-path-guard.yml +++ b/.github/workflows/single-claim-path-guard.yml @@ -17,8 +17,9 @@ # is authoritative and this file is the invocation. # # Sibling shape, deliberately copied rather than reinvented: the Part-of -# Closing-Keyword Guard is this repo's other single-script PR-scoped blocking -# check, and this takes its job shape, its runtime pin and its trigger set. +# Closing-Keyword Guard is one of this repo's other single-script PR-scoped +# blocking checks, and this takes its job shape, its runtime pin and its trigger +# set. name: Single-Claim Path Guard # `synchronize` is the load-bearing one here, and that is the difference from diff --git a/package.json b/package.json index d7e5d4f224..10a78c038c 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "check:partof-closing-keyword": "node scripts/check-partof-closing-keyword.mjs --self-test", "check:commit-card-trailers": "node scripts/check-commit-card-trailers.mjs --self-test", "check:single-claim-paths": "node scripts/check-single-claim-paths.mjs --self-test", + "check:closing-target-claim": "node scripts/check-closing-target-claim.mjs --self-test", "check:pnpm-filter-targets": "node scripts/pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs", "check:turbo-task-graph": "node scripts/check-turbo-task-graph.mjs --self-test && node scripts/check-turbo-task-graph.mjs", "check:workspace-manifest-cycles": "node scripts/check-workspace-manifest-cycles.mjs --self-test && node scripts/check-workspace-manifest-cycles.mjs", diff --git a/scripts/check-closing-target-claim.mjs b/scripts/check-closing-target-claim.mjs new file mode 100644 index 0000000000..71e0d3f4e0 --- /dev/null +++ b/scripts/check-closing-target-claim.mjs @@ -0,0 +1,1000 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check:closing-target-claim — a pull request may close a card only while that + * card's own thread carries a `Claim:` naming this pull request's HEAD BRANCH. + * + * node scripts/check-closing-target-claim.mjs # judge this PR (CI) + * node scripts/check-closing-target-claim.mjs --self-test # verify it offline + * + * ⚠️ Repo paths are named UNQUOTED in this header on purpose, and the self-test + * fixtures below use branches and repos that exist nowhere. Both are + * load-bearing; the last section carries the measurement that forces them. + * + * ## The defect this blocks + * + * Every agent in this repository shares ONE GitHub identity, so the assignee + * field is a presence bit and the `Claim:` comment — first line beginning + * `Claim:`, with a `Branch:` line naming `claude/issue-N-slug` — is the + * identity record. Two guards already run at pull-request time: the Duplicate + * Fix Guard asks whether another OPEN PR claims the same card, and the + * Single-Claim Path Guard asks whether another open PR writes the same + * at-most-one-writer path. Neither asks the question that actually decides + * ownership: does the card this PR CLOSES carry a claim naming this PR's + * branch? + * + * That question was already asked in this tree — by H46 in + * scripts/pm/check-half-states.mjs — and asked AFTER THE FACT, as a report-only + * patrol input. The cost of that timing is measured twice, which makes it a + * rate and not an anecdote: + * + * - the cloud-repo incident: an epic seat and a lane PM implementing ONE card + * 29 minutes apart, the second with no claim comment at all; + * - an objectstack card implemented TWICE in one morning. + * + * Both were visible to H46's predicate and stopped by nothing. ⭐ A control that + * detects a defect it cannot prevent is a TIMING defect in an existing control, + * not an absent one — so what ships here is the promotion of H46's leg (b) to a + * check that runs while the merge can still be stopped, and ⛔ NOT a second + * predicate. + * + * ## Everything decisive is IMPORTED, never re-spelled + * + * Three readings, three shipped owners: + * + * - the closing-keyword grammar is `closingKeywordTargets`, H7's and H21's + * and H46's, the module parser that check-closing-keyword-parity holds + * behaviourally equal to the two workflow spellings. A FOURTH spelling here + * would be the one nobody remembers when the grammar next moves. + * - the claim predicate is `h46ClaimNamesBranch`, which is itself + * `CLAIM_COMMENT_MARKER` plus `claimedBranches` — the same pair + * check-clause2-carriers reads. ⭐ "Import, never restate." + * - the classification of everything that is NOT a claim on this branch is + * `claimGovernance`, the three-valued reader check-clause2-carriers already + * uses, and the next section is why a blocking gate needs all three values. + * + * The POSITIVE predicate is deliberately H46's and not governance's, so the + * patrol row and this gate can never disagree about the word CLAIMED. The two + * differ only on a thread where an OLDER claim names this branch and a NEWER + * one names another: H46 calls that claimed and so does this gate. That is the + * permissive direction — a blocking gate erring toward not-blocking on an + * ambiguous thread — and it is the no-fork property, not an accident. + * - the merge-queue ref reading is `pullNumberFromQueueRef`, the governed + * queue guard's, which already carries the `release/v5`-style base-branch + * lesson its own header records. + * + * ## What is deliberately NOT changed + * + * ⛔ What a claim IS. The single-character separator is a maintainer ruling of + * 2026-08-11 recorded verbatim at `CLAIM_COMMENT_MARKER`, and the repair + * direction for a malformed claim is the WRITE side. This gate reads the + * shipped predicate and widens nothing. + * + * ⛔ Any assignee-field logic. The field is a presence bit under a shared + * identity; it answers no ownership question and is not read here. + * + * ⛔ H46 itself. It stays exactly as it is — the patrol's after-the-fact view, + * still covering pull requests whose CI predates this gate, still report-only, + * still exporting the predicate this file calls. Two consumers, one predicate, + * no fork. + * + * ⛔ A `Part of #N` target. It is not a closing keyword and never enters this + * gate's target set: a body declaring itself part of a card is not telling + * GitHub to finish it, and `closingKeywordTargets` already excludes it. + * + * ## The branch-rename edge, decided here rather than discovered in CI + * + * A seat that claims correctly and then RENAMES or RE-CREATES its branch leaves + * a claim naming a ref that no longer exists, and fails a gate it satisfied in + * substance. Two repairs were available: name the case in the failure text, or + * widen the predicate to accept any claim from the same SESSION. + * + * ⇒ The failure text names it. Widening to a session identity would make this + * gate the second reader of what a claim IS — the one change the 2026-08-11 + * ruling closes — and it would accept a claim whose `Branch:` line points at a + * ref nobody can find, which is the state H20 exists to report. The remedy is + * one comment, it is the same act the protocol already requires, and the + * failure text spells it out rather than leaving the first person it blocks to + * work it out. + * + * ## Cost — bounded by the PR's closing-keyword count, and cheap on the + * ## overwhelmingly common shape + * + * A body binding no closing keyword makes ZERO API calls and prints one line. + * Otherwise, per closing target: + * + * 1. one comment page (`per_page=100`). The claim is found here on every + * thread this board has, so this is where the common path ends. + * 2. only when NO claim was found: one issue read, to classify the number + * before accusing anyone of anything. This is the short-circuit on the + * cheap side — the second read happens only on the path about to go red. + * + * The page walk is capped, and a thread that could not be walked to the end is + * UNDETERMINED rather than unclaimed: absence over a truncated read is not + * absence (#4690), and a degraded reading must never be folded into either + * verdict. + * + * ## ⭐ An UNPARSED claim is UNCLASSIFIED, and ⛔ never a "no" + * + * This is the one place where promoting a report-only row to a blocking check + * changes what the answer has to be, so it is stated rather than inherited. + * + * `CLAIM_COMMENT_MARKER` decides whether a comment IS a claim; `claimedBranches` + * reads only a `Branch:` DIRECTIVE line. A claim that writes its branch on the + * `Claim:` line itself, after a separator, is therefore a claim that yields ZERO + * branches — and that shape is not hypothetical. It was measured live on the + * card this gate was written for, and on a second dispatched card the same + * morning: the comment is a claim to `CLAIM_COMMENT_MARKER`, to a human and to a + * grep, and names no branch to the reader that matters. + * + * For the patrol row that is a row. For a gate that fails builds it would be a + * false RED on a card that really is claimed, on the very branch being judged — + * and a dispatched executor ⛔ may not post a second claim to clear it, because + * the dispatch's claim is its identity. So the malformed case takes the third + * value `claimGovernance` exists to return: UNDETERMINED, warned about by name, + * never folded into either verdict. + * + * ⛔ The repair is NOT to widen the branch reader for whichever spelling was + * measured last — that treadmill closes one spelling and leaves the next one + * exactly as silent, and widening what a claim IS is the one change the + * 2026-08-11 ruling closes. The repair direction is the WRITE side, and the + * patrol rows that report a malformed claim are what drive it. + * + * ## Which numbers are judged, and which are declined + * + * A closing keyword can bind a number that is not an open card, and accusing + * its author would make this gate noise on day one. Declined, each for its own + * reason and each said out loud: + * + * - the number names a PULL REQUEST. A PR carries no claim and was never on + * the board; `Closes #` is a relation between two PRs. + * - the card is already CLOSED. The merge closes nothing, so there is no + * second implementation to prevent. This is H46's open-cards scope, reached + * by a read instead of by a listing. + * - the number could not be read at all — UNDETERMINED, warned about, and + * never counted as clean. + * + * ## The merge queue, asserted rather than assumed + * + * The card behind this gate states that merge-queue builds see the same PR body + * so the check holds in the queue too. That is TRUE HERE only because this file + * makes it true, and the mechanism is worth stating because the two sibling + * PR-scoped guards deliberately do the opposite: + * + * - a `merge_group` event carries NO pull request and therefore no body. Both + * body-scoped siblings stop there and take no queue leg. + * - this gate takes one anyway, because the queue ref NAMES its pull request: + * `gh-readonly-queue//pr--`, read by the imported + * `pullNumberFromQueueRef`. With the number in hand the body and the head + * ref are one ordinary read away, so the queue leg judges the SAME PR BODY + * against the SAME live comment threads. Nothing is re-derived and no + * second rule appears on the queue side: both legs end in the same + * `collect` and the same `judge`. + * + * ⚠️ In a MULTI-PR group the queue ref names only the LAST pull request — the + * limit `pullNumberFromQueueRef`'s own header states. It is not a hole in the + * regime: every member of that group passed this gate's `pull_request` leg to + * be armed at all, and the queue leg is the re-verification on the rebuilt + * generation for the one it can name. It IS a limit on what the queue leg alone + * proves, and it is stated rather than left to be discovered. + * + * ⛔ This PR does not add this check to the required set. A required context is + * a `REQUIRED_CONTEXTS` row PLUS a Settings ruleset entry in one sitting, and + * the settings half is the maintainer's. What the `merge_group` leg buys today + * is that the question is ASKED on the last thing between a speculative merge + * and the default branch; what it buys tomorrow is that required-izing this + * context cannot deadlock the queue, which a workflow with no queue leg always + * does. + * + * ## Exit codes — and why "could not tell" is never 0 + * + * 0 judged, clean. + * 1 judged, finding: a card this PR closes records no claim on this branch. + * 2 NOT MEASURED — the run was handed no usable context (no `PR_NUMBER` and + * no queue ref, an empty repo slug or token, a head ref this gate cannot + * read), or the queue leg could not read its pull request. A usage, + * wiring or transport failure, never a verdict about any PR. + * + * A gate that cannot read its input has verified nothing, and exiting 0 there + * reads as "no violations". The inverse matters as much: a mis-wired gate must + * not read as an accusation, because it would be red on every PR at once for + * something no author did. + * + * ## The remedy is read LIVE, which the body-scoped siblings cannot say + * + * Those guards judge a frozen payload, so `rerun_failed_jobs` replays a stale + * body and a fixed PR stays red forever; their way back to green is a body EDIT + * that fires a fresh event. Here the decisive input — the card's comments — is + * read live on every run, so posting the claim and RE-RUNNING the job is enough + * and no push is needed. The body is still the frozen half, so the OTHER remedy + * (dropping the closing keyword) needs a fresh event, which the `edited` + * activity type supplies. + * + * ## Why the paths above are unquoted, and the fixtures fictional + * + * The dispatch-gates derivation resolves a check family to its script file and + * scans THAT FILE for quoted path literals as watch hints, comments and test + * fixtures included. Written the ordinary way, this header would emit a hint + * per path and fabricate MATCHED leads for cards touching none of this. So repo + * paths are named unquoted in prose here; the only quoted paths below are the + * two real inputs the self-test reads. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; +import { isEntrypoint } from './invoked-as.mjs'; + +// Imported, never restated. The closing-keyword grammar and the claim +// predicate both have exactly one home in this tree, and this gate is their +// second consumer rather than a second spelling. +import { claimGovernance, closingKeywordTargets, h46ClaimNamesBranch } from './pm/check-half-states.mjs'; +import { pullNumberFromQueueRef } from './pm/check-governed-queue-guard.mjs'; + +// ── The self-test's own battery roster and floor (#13489) ────────────────── +// +// A self-test whose only success condition is "no failure was recorded" prints +// the same line for "every case held" and "the cases never ran". Every section +// opens with `battery('')`, every assertion is attributed to the battery +// most recently opened, and the floor requires the OPENED set to equal the +// DECLARED set with each battery at or above its own count. +// +// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 keeps +// a total "right" the moment a sibling grows. A set difference says WHICH +// battery stopped; a count says only that something did. +// +// The counts are a FLOOR, not an equality — adding cases is ordinary work and +// must not red. +const SELF_TEST_BATTERIES = Object.freeze({ + 'The acceptance pair. Red and GREEN, because a probe tested only on its': 8, + 'The negative controls: a PR that closes nothing and a PR that is only': 7, + 'Delegation, not a second copy of the rule. The targets and the claim': 5, + 'The failure has to carry the card, the branch and every remedy — the': 10, + 'Declined numbers. A closing keyword can bind something that is not an': 6, + 'UNDETERMINED is its own answer: never clean, never an accusation.': 9, + 'Wiring absent: never clean, never an accusation.': 12, + 'The merge-queue leg, asserted rather than assumed.': 9, + 'The wiring itself. A gate whose workflow step is deleted or whose': 10, + 'The predicate sources this gate reuses must still be there to reuse.': 3, +}); + +// DELETING an entry silences that battery's floor exactly as effectively as +// zeroing it, so the roster's own size is pinned too. +const SELF_TEST_BATTERY_FLOOR = 10; + +// The key an assertion is filed under when no battery is open. It is not a +// declared battery, so it reds by the same set difference rather than silently +// inflating whichever battery happened to run last. +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +// ⚠️ None of these helpers is named with a self-test spelling, deliberately and +// on the record: `check:pm-dispatch-gates` anchors on a top-level declaration +// whose NAME spells self-test, and every such name owes a row in that gate's +// COMPOUND_ANCHOR_LEDGER. These are the battery ROSTER's machinery -- they hold +// no fixtures to mask and read no path literal -- so the accurate name is the +// one that says `battery`. + +/** Cases registered per battery: `battery()` opens one, `registerCase()` files into it. */ +const batteryCases = new Map(); +let openBattery = null; + +/** Open a battery. Every assertion after this line is attributed to it. */ +function battery(name) { + openBattery = name; +} + +/** Called by the self-test's own assertion sink, once per assertion. */ +function registerCase() { + const name = openBattery ?? UNATTRIBUTED_BATTERY; + batteryCases.set(name, (batteryCases.get(name) ?? 0) + 1); +} + +/** + * The floor: every declared battery RAN, and ran its cases (#13489). + * + * Evaluated after every battery has had its chance and BEFORE the verdict, so + * the success line can only be printed by a run in which the set of batteries + * that registered assertions EQUALS the set declared. + */ +function batteryFloorFailures() { + const declared = Object.keys(SELF_TEST_BATTERIES); + const problems = []; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + problems.push( + `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ` + + `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, + ); + } + for (const [name, count] of batteryCases) { + if (declared.includes(name)) continue; + problems.push( + `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + + 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', + ); + } + for (const name of declared) { + const count = batteryCases.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + problems.push( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + + 'The verdict below would have claimed those cases hold.' + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + + `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + if (problems.length) { + problems.push( + 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + + 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + + 'skips) and restore it.', + ); + } + return problems; +} + +const ROOT = new URL('..', import.meta.url).pathname; + +/** The wiring that gives this gate a pull request to judge. */ +const WIRING_WORKFLOW = '.github/workflows/closing-target-claim-guard.yml'; + +/** + * ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it is + * duplicated in the workflow's `name:` — deliberately, and pinned in both + * directions by the self-test below. Renaming a job silently detaches a + * required context, and this is the cheap half of the two-step that makes that + * impossible to do by accident. + */ +export const CHECK_CONTEXT_NAME = 'The card this PR closes must claim this branch'; + +/** The predicate homes this gate reuses, and must move when they move. */ +const PREDICATE_SOURCES = ['scripts/pm/check-half-states.mjs', 'scripts/pm/check-governed-queue-guard.mjs']; + +export const EXIT_CLEAN = 0; +export const EXIT_UNCLAIMED = 1; +export const EXIT_NOT_WIRED = 2; + +/** A comment thread is walked at most this far; past it the thread is UNDETERMINED, never unclaimed. */ +export const MAX_COMMENT_PAGES = 10; + +/** How a single closing target was resolved. One verdict per target, never a shrug. */ +export const TARGET_CLAIMED = 'claimed'; +export const TARGET_UNCLAIMED = 'unclaimed'; +export const TARGET_NOT_A_CARD = 'not-a-card'; +export const TARGET_CLOSED = 'closed'; +export const TARGET_UNDETERMINED = 'undetermined'; + +const NOT_WIRED_REASON = Object.freeze({ + CONTEXT: 'neither PR_NUMBER nor MERGE_GROUP_HEAD_REF is set', + GITHUB_REPOSITORY: 'GITHUB_REPOSITORY is not set (or set to an empty string)', + GITHUB_TOKEN: 'GITHUB_TOKEN is not set (or set to an empty string)', + PR_HEAD_REF: 'PR_HEAD_REF is not set (or set to an empty string), and the head branch IS the question', + MERGE_GROUP_HEAD_REF: 'MERGE_GROUP_HEAD_REF names no merge-queue pull request', +}); + +/** + * The run context, or a NOT-WIRED marker, or null. + * + * Three-way for the sibling gate's reason: "nothing was handed to this run" and + * "something was handed but the run is still unusable" are different failures + * with different remedies, and BOTH must route to EXIT_NOT_WIRED rather than to + * the finding code or to a request built from an empty slug. + * + * `PR_NUMBER` is read by PRESENCE, not truthiness: its value witnesses that the + * workflow ran this step and is never spliced into a URL on the pull_request + * leg. `GITHUB_REPOSITORY`, `GITHUB_TOKEN` and `PR_HEAD_REF` are read by + * truthiness, deliberately a DIFFERENT convention, because each is consumed + * directly — two into the request, one into the predicate — so for them an + * empty string is the SAME failure as an absent variable rather than a + * different one. + */ +export function readPrContext(env) { + const queueRef = String(env.MERGE_GROUP_HEAD_REF ?? '').trim(); + const wired = Object.hasOwn(env, 'PR_NUMBER') || queueRef !== ''; + if (!wired) return null; + + const repo = String(env.GITHUB_REPOSITORY ?? '').trim(); + if (!repo) return { wired: false, missing: 'GITHUB_REPOSITORY' }; + + const token = String(env.GITHUB_TOKEN ?? '').trim(); + if (!token) return { wired: false, missing: 'GITHUB_TOKEN' }; + + if (queueRef !== '') { + const number = pullNumberFromQueueRef(queueRef); + if (number === null) return { wired: false, missing: 'MERGE_GROUP_HEAD_REF' }; + // The queue leg carries no body and no head ref; `collect` reads both from + // the pull request the ref names, so the two legs converge before judging. + return { source: 'merge_group', number: String(number), repo, token, queueRef, body: null, head: null }; + } + + const head = String(env.PR_HEAD_REF ?? '').trim(); + if (!head) return { wired: false, missing: 'PR_HEAD_REF' }; + + return { source: 'pull_request', number: String(env.PR_NUMBER ?? '').trim(), repo, token, body: env.PR_BODY ?? '', head }; +} + +function notWiredVerdict(reasonText) { + return { + exit: EXIT_NOT_WIRED, + lines: [ + `check:closing-target-claim: NOT MEASURED — ${reasonText}, so this run was handed no usable pull`, + 'request context and judged nothing. This is a wiring, usage or transport failure, NOT a verdict:', + 'it says nothing about whether any PR closes a card that never claimed its branch, and no author', + 'caused it.', + '', + `Fix: run it from the workflow that supplies the context (${WIRING_WORKFLOW}), or locally with`, + ' PR_NUMBER=123 PR_HEAD_REF=claude/issue-123-slug PR_BODY="Closes #123" \\', + ' GITHUB_REPOSITORY=owner/repo GITHUB_TOKEN=... node scripts/check-closing-target-claim.mjs', + ], + }; +} + +/** + * The verdict: `{ exit, lines }`, pure over its input, so the self-test drives + * every arm with no network at all. + * + * `ctx.targets` is the collected result — one row per closing target, each + * carrying its own verdict. A row is never omitted: a declined number is a + * DECISION with a reason, not a silence. + */ +export function judge(ctx) { + if (ctx === null) return notWiredVerdict(NOT_WIRED_REASON.CONTEXT); + if (ctx.wired === false) return notWiredVerdict(NOT_WIRED_REASON[ctx.missing]); + if (ctx.unreadable) return notWiredVerdict(ctx.unreadable); + + const where = ctx.number ? `PR #${ctx.number}` : 'this PR'; + const leg = ctx.source === 'merge_group' ? ' (merge-queue build)' : ''; + const targets = ctx.targets ?? []; + + if (targets.length === 0) { + return { + exit: EXIT_CLEAN, + lines: [ + `✓ check:closing-target-claim: ${where}${leg} binds no closing keyword to a card, so it can ` + + 'attribute none. A `Part of #N` declaration is not a closing target and is not checked.', + ], + }; + } + + const undetermined = targets.filter((t) => t.verdict === TARGET_UNDETERMINED); + const unclaimed = targets.filter((t) => t.verdict === TARGET_UNCLAIMED); + const undeterminedLines = undetermined.map( + (t) => + `::warning::UNDETERMINED — #${t.number} was not judged: ${t.why}. This run could not prove ` + + `whether any \`Claim:\` on it names \`${ctx.head}\`, and an unprovable absence is ⛔ not an absence.`, + ); + + if (unclaimed.length === 0) { + const claimed = targets.filter((t) => t.verdict === TARGET_CLAIMED).map((t) => `#${t.number}`); + const declined = targets.filter((t) => t.verdict === TARGET_NOT_A_CARD || t.verdict === TARGET_CLOSED); + return { + exit: EXIT_CLEAN, + lines: [ + ...undeterminedLines, + claimed.length > 0 + ? `✓ check:closing-target-claim: ${where}${leg} closes ${claimed.join(', ')}, and each carries a ` + + `\`Claim:\` whose \`Branch:\` line names \`${ctx.head}\`.` + : `✓ check:closing-target-claim: ${where}${leg} closes no card this run could hold against a ` + + 'claim; every closing target it binds is accounted for below.', + ...declined.map( + (t) => + ` #${t.number} was not judged: ${t.verdict === TARGET_NOT_A_CARD ? 'it names a pull request, which carries no claim' : 'the card is already closed, so the merge closes nothing'}.`, + ), + ...(undetermined.length > 0 + ? [` ${undetermined.length} closing target(s) could not be judged — see the UNDETERMINED warning(s) above.`] + : []), + ], + }; + } + + const rows = unclaimed.map((t) => + (t.names ?? []).length === 0 + ? ` - \`${t.keyword} #${t.number}\` — no comment on #${t.number} is a \`Claim:\` at all, so the card ` + + `records no owner and nothing on it would have stopped a second seat.` + : ` - \`${t.keyword} #${t.number}\` — the governing \`Claim:\` on #${t.number} names ` + + `${(t.names ?? []).map((b) => `\`${b}\``).join(', ')}, not \`${ctx.head}\`.`, + ); + + return { + exit: EXIT_UNCLAIMED, + lines: [ + ...undeterminedLines, + `::error::${where} closes ${unclaimed.map((t) => `#${t.number}`).join(', ')} with no \`Claim:\` on ` + + `the card naming \`${ctx.head}\``, + '', + `✗ check:closing-target-claim: ${where}${leg} is set to close a card that never recorded this branch`, + ' as its claim.', + '', + ...rows, + '', + ' Why this is blocking rather than advisory: every agent here shares one GitHub identity, so the', + ' assignee field is a presence bit and the `Claim:` comment is the only identity record on the', + ' board. With no claim naming this branch, nothing on that card stopped a second seat taking it —', + ' and that has cost a whole duplicated dev round twice, once 29 minutes apart and once twice in one', + ' morning. Both were visible to the patrol row that asks this question AFTER the merge, and stopped', + ' by nothing.', + '', + ' Remedy, in order of preference:', + ` 1. Post the claim on the card, in the fixed spelling: a comment whose FIRST line begins`, + ` \`Claim:\` and which carries a \`Branch:\` line naming \`${ctx.head}\`. The separator is one`, + ' ASCII colon — a dash-written or fullwidth-written claim is a malformed claim, not a dialect,', + ' and widening the reader is the one repair the 2026-08-11 ruling closes. The comment threads', + ' are read LIVE on every run, so re-running this job after posting is enough: no push needed.', + ` 2. Claimed already, then RENAMED or RE-CREATED the branch? The old claim still names the old`, + ` ref, which is why this is red. Re-post the claim naming the current branch, \`${ctx.head}\`.`, + ' 3. Not this card\'s implementation? Drop the closing keyword and write `Part of #N` instead —', + ' a `Part of` target is not a closing target and this gate never reads it. Editing the body', + ' fires a fresh event, so that route goes green with no push and no re-run.', + '', + ' ⛔ Do not take the card away from whoever holds it. A `Claim:` from another session or branch', + ' means TAKEN, whatever the assignee field says.', + ], + }; +} + +// --------------------------------------------------------------------------- +// Collection — the only part that touches the network. +// +// The cheap question first: a body binding no closing keyword makes zero calls. +// Then, per target, one comment page; the classifying read happens ONLY on the +// path that is about to go red. +// --------------------------------------------------------------------------- + +/** + * Every comment body on one card, and whether the thread was walked to its end. + * + * `complete: false` is never folded into the answer: a claim that would be on + * page 11 is indistinguishable here from a claim that was never written, and + * the two have opposite verdicts. + */ +async function listIssueComments(api, repo, number) { + const rows = []; + for (let page = 1; page <= MAX_COMMENT_PAGES; page++) { + const batch = await api(`/repos/${repo}/issues/${number}/comments?per_page=100&page=${page}`); + for (const comment of batch) rows.push(comment ?? {}); + if (batch.length < 100) return { rows, complete: true }; + } + return { rows, complete: false }; +} + +/** + * Resolve every closing target of this PR's body to exactly one verdict. + * + * `api` returns parsed JSON or throws; `apiOrNull` turns a throw into a + * declared UNDETERMINED rather than an unhandled rejection, because a transport + * hiccup is not something a PR author did. + */ +export async function collect(ctx, api) { + const apiOrNull = async (path) => { + try { + return await api(path); + } catch { + return null; + } + }; + + let body = ctx.body; + let head = ctx.head; + if (ctx.source === 'merge_group') { + const pr = await apiOrNull(`/repos/${ctx.repo}/pulls/${ctx.number}`); + if (!pr) { + return { + ...ctx, + unreadable: + `the merge-queue ref named PR #${ctx.number} but that pull request could not be read`, + }; + } + body = pr.body ?? ''; + head = String(pr.head?.ref ?? '').trim(); + if (!head) { + return { ...ctx, unreadable: `PR #${ctx.number} reported no head branch, and the head branch IS the question` }; + } + } + + const targets = []; + for (const [number, keyword] of closingKeywordTargets(body ?? '')) { + let rows = null; + let complete = true; + try { + const walked = await listIssueComments(api, ctx.repo, number); + rows = walked.rows; + complete = walked.complete; + } catch { + targets.push({ number, keyword, verdict: TARGET_UNDETERMINED, why: 'its comment thread could not be read' }); + continue; + } + + const governance = claimGovernance(rows); + if (h46ClaimNamesBranch(rows.map((row) => row?.body ?? ''), head)) { + targets.push({ number, keyword, verdict: TARGET_CLAIMED, malformed: governance.malformed !== null }); + continue; + } + if (!complete) { + targets.push({ + number, + keyword, + verdict: TARGET_UNDETERMINED, + why: `its thread is longer than ${MAX_COMMENT_PAGES} pages of 100 comments`, + }); + continue; + } + // ⭐ An unparsed claim is an UNCLASSIFIED result, ⛔ never a "no" — the + // ruling `claimGovernance` carries. The newest claim-shaped comment on this + // card yielded zero branches, so this run cannot tell a card claimed on + // another branch from one claimed on THIS branch in a spelling the shipped + // reader does not parse. A blocking gate must not turn that into an + // accusation, and widening the reader is the one repair the 2026-08-11 + // ruling closes. + if (governance.malformed) { + targets.push({ + number, + keyword, + verdict: TARGET_UNDETERMINED, + why: 'its newest `Claim:` comment carries no parseable `Branch:` line, so what it claims is unreadable', + }); + continue; + } + + // Only now — on the path about to accuse someone — is the second read spent. + const issue = await apiOrNull(`/repos/${ctx.repo}/issues/${number}`); + if (!issue) { + targets.push({ number, keyword, verdict: TARGET_UNDETERMINED, why: 'the number itself could not be read' }); + continue; + } + if (issue.pull_request) { + targets.push({ number, keyword, verdict: TARGET_NOT_A_CARD }); + continue; + } + if (issue.state !== 'open') { + targets.push({ number, keyword, verdict: TARGET_CLOSED }); + continue; + } + targets.push({ number, keyword, verdict: TARGET_UNCLAIMED, names: governance.governing?.branches ?? [] }); + } + + return { ...ctx, body, head, targets }; +} + +const githubApi = (token) => async (path) => { + const response = await fetch(`https://api.github.com${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (!response.ok) throw new Error(`GitHub API ${response.status} for ${path}`); + return response.json(); +}; + +// --------------------------------------------------------------------------- +// Self-test — the verdict layer, the exit-code contract, the collection policy +// over a fake transport, and the wiring. +// +// The two predicates this gate reuses are tested where they live; what is +// pinned here is that this gate really DELEGATES to them rather than carrying a +// second copy of either, that both halves of the acceptance pair hold (a +// correct claim is GREEN, a claim naming another branch is RED), that the +// negative controls add no output at all, and that the queue leg exists and +// ends in the same judgement as the pull-request leg. +// --------------------------------------------------------------------------- + +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` that leaves the function above that line prints nothing +// and still exits 0 — a self-test that never finished, reported as one that +// passed (#13798). +let selfTestReachedVerdict = false; + +/** A fake transport: a route table plus a call log, so cost is an assertion and not a claim. */ +function fakeApi(routes) { + const calls = []; + const api = async (path) => { + calls.push(path); + const key = Object.keys(routes).find((prefix) => path.startsWith(prefix)); + if (key === undefined) throw new Error(`no route for ${path}`); + const value = routes[key]; + if (value instanceof Error) throw value; + return value; + }; + return { api, calls }; +} + +async function selfTest() { + const cases = []; + const t = (name, actual, expected) => { + registerCase(); + return cases.push([name, actual, expected]); + }; + + const HEAD = 'claude/issue-15845-claim-comment-pr-gate'; + const OTHER = 'claude/issue-15845-someone-else'; + const claim = (ref) => ({ body: `Claim: session_0deadbeef\nBranch: \`${ref}\`` }); + const ctxOf = (patch) => ({ + source: 'pull_request', + number: '900', + repo: 'o/r', + token: 't', + body: '', + head: HEAD, + targets: [], + ...patch, + }); + const verdict = (patch) => judge(ctxOf(patch)); + const text = (v) => v.lines.join('\n'); + + // Collection over the fake transport, so both the VERDICT and the CALL COST + // are measured rather than asserted in prose. + const run = async (body, routes, patch = {}) => { + const { api, calls } = fakeApi(routes); + const collected = await collect(ctxOf({ body, targets: undefined, ...patch }), api); + return { verdict: judge(collected), calls, collected }; + }; + + // --- The acceptance pair. Red and GREEN, because a probe tested only on its + // red half has not been shown to reach the card at all. + battery('The acceptance pair. Red and GREEN, because a probe tested only on its'); + const green = await run('Closes #15845', { '/repos/o/r/issues/15845/comments': [claim(HEAD)] }); + t('a card carrying a `Claim:` that names this head branch is CLEAN', green.verdict.exit, EXIT_CLEAN); + t('…and the clean line names the card it verified', text(green.verdict).includes('#15845'), true); + t('…and it cost exactly one comment page — the card\'s stated bound', green.calls.length, 1); + t('…with no classifying read spent on a card that is fine', green.calls.some((c) => c === '/repos/o/r/issues/15845'), false); + + const red = await run('Closes #15845', { + '/repos/o/r/issues/15845/comments': [claim(OTHER)], + '/repos/o/r/issues/15845': { state: 'open', number: 15845 }, + }); + t('a card whose only `Claim:` names ANOTHER branch is a FINDING', red.verdict.exit, EXIT_UNCLAIMED); + t('…and the red path costs one comment page plus ONE classifying read, never more', red.calls.length, 2); + t('a card with NO claim comment at all is a FINDING', (await run('Closes #15845', { + '/repos/o/r/issues/15845/comments': [{ body: 'Triage: p2.' }], + '/repos/o/r/issues/15845': { state: 'open', number: 15845 }, + })).verdict.exit, EXIT_UNCLAIMED); + t('an EMPTY thread is a FINDING too — nothing there claims anything', (await run('Closes #15845', { + '/repos/o/r/issues/15845/comments': [], + '/repos/o/r/issues/15845': { state: 'open', number: 15845 }, + })).verdict.exit, EXIT_UNCLAIMED); + + // --- The negative controls: a PR that closes nothing and a PR that is only + // `Part of` a card must be UNAFFECTED, and must add no output of their own. + battery('The negative controls: a PR that closes nothing and a PR that is only'); + const nothing = await run('An ordinary body with no card relation at all.', {}); + t('a PR that closes nothing is clean', nothing.verdict.exit, EXIT_CLEAN); + t('…and makes ZERO API calls', nothing.calls.length, 0); + t('…and emits no error annotation', text(nothing.verdict).includes('::error::'), false); + t('…and emits no warning annotation', text(nothing.verdict).includes('::warning::'), false); + + const partOf = await run('Part of #15845 — the gate half lands separately.', {}); + t('a PR carrying only `Part of` is clean', partOf.verdict.exit, EXIT_CLEAN); + t('…and makes ZERO API calls, so a `Part of` target is never even read', partOf.calls.length, 0); + t('…and the clean line says `Part of` is not a closing target', text(partOf.verdict).includes('`Part of #N`'), true); + + // --- Delegation, not a second copy of the rule. The targets and the claim + // predicate are the shipped ones; a fork would pass the cases above and drift + // from the sweep on the next grammar move. + battery('Delegation, not a second copy of the rule. The targets and the claim'); + const grammar = ['Closes #1', 'Fixes: #2', 'Part of #3', 'resolved #4', '', 'Part of #5\n\nFixes #6']; + const collectedNumbers = []; + for (const body of grammar) { + const { api } = fakeApi({ '/repos/o/r/issues/': [] }); + const out = await collect(ctxOf({ body, targets: undefined }), api); + collectedNumbers.push((out.targets ?? []).map((row) => String(row.number))); + } + t( + 'the target set is exactly `closingKeywordTargets` over every fixture (no forked grammar)', + collectedNumbers, + grammar.map((body) => [...closingKeywordTargets(body)].map(([n]) => String(n))), + ); + t('…which for `Part of #3` alone is the empty set', collectedNumbers[2], []); + t('…and for a body that is `Part of` one card while closing another, only the closed one', collectedNumbers[5], ['6']); + + const claimFixtures = [[claim(HEAD).body], [claim(OTHER).body], ['Claim: no branch line here'], ['ordinary prose'], []]; + const delegated = []; + for (const bodies of claimFixtures) { + const { api } = fakeApi({ + '/repos/o/r/issues/15845/comments': bodies.map((body) => ({ body })), + '/repos/o/r/issues/15845': { number: 15845, state: 'open' }, + }); + const out = await collect(ctxOf({ body: 'Closes #15845', targets: undefined }), api); + delegated.push(out.targets[0].verdict === TARGET_CLAIMED); + } + t( + 'a target is CLAIMED exactly when the shipped `h46ClaimNamesBranch` says so (no forked predicate)', + delegated, + claimFixtures.map((bodies) => h46ClaimNamesBranch(bodies, HEAD)), + ); + t( + 'the documented blockquote claim reads, unrespelled', + (await run('Closes #15845', { '/repos/o/r/issues/15845/comments': [{ body: `> Claim: wave 9\n> Branch: \`${HEAD}\`` }] })).verdict.exit, + EXIT_CLEAN, + ); + t( + '…and so do the shipped `Claimed:` / `Branches:` spellings', + (await run('Closes #15845', { '/repos/o/r/issues/15845/comments': [{ body: `Claimed: x\nBranches: \`${HEAD}\`` }] })).verdict.exit, + EXIT_CLEAN, + ); + + // --- The failure has to carry the card, the branch and every remedy — the + // card's own requirement, and triage's branch-rename edge is one of them. + battery('The failure has to carry the card, the branch and every remedy — the'); + const failed = text(red.verdict); + t('the failure names the CARD', failed.includes('#15845'), true); + t('the failure names the BRANCH', failed.includes(HEAD), true); + t('the failure names the keyword that bound it', failed.includes('`Closes #15845`'), true); + t('remedy 1 — post the claim in the fixed spelling', failed.includes('FIRST line begins'), true); + t('remedy 1 names the `Branch:` line the claim must carry', failed.includes('`Branch:` line naming'), true); + t('remedy 2 — the RENAMED/RE-CREATED branch case, named rather than discovered', failed.includes('RENAMED or RE-CREATED'), true); + t('remedy 3 — drop the closing keyword for `Part of`', failed.includes('`Part of #N`'), true); + t('the failure is annotated for the GitHub UI', failed.includes('::error::'), true); + t('a claim naming ANOTHER branch says so, and quotes the branch it does name', failed.includes(`names \`${OTHER}\``), true); + const noClaim = await run('Closes #15845', { + '/repos/o/r/issues/15845/comments': [{ body: 'Triage: p2.' }], + '/repos/o/r/issues/15845': { number: 15845, state: 'open' }, + }); + t( + '…and a card with no claim at ALL gets the OTHER sentence, because the remedies differ', + text(noClaim.verdict).includes('is a `Claim:` at all'), + true, + ); + + // --- Declined numbers. A closing keyword can bind something that is not an + // open card, and accusing its author would make this gate noise on day one. + battery('Declined numbers. A closing keyword can bind something that is not an'); + const prTarget = await run('Closes #16700', { + '/repos/o/r/issues/16700/comments': [], + '/repos/o/r/issues/16700': { number: 16700, state: 'open', pull_request: { url: 'x' } }, + }); + t('a closing keyword bound to a PULL REQUEST is not a finding', prTarget.verdict.exit, EXIT_CLEAN); + t('…and the clean line SAYS the number was declined, rather than staying silent', text(prTarget.verdict).includes('names a pull request'), true); + + const closedCard = await run('Closes #15667', { + '/repos/o/r/issues/15667/comments': [], + '/repos/o/r/issues/15667': { number: 15667, state: 'closed' }, + }); + t('a closing keyword bound to an already-CLOSED card is not a finding', closedCard.verdict.exit, EXIT_CLEAN); + t('…and the clean line says why it was declined', text(closedCard.verdict).includes('already closed'), true); + t('the classifying read is spent, and only here', closedCard.calls.includes('/repos/o/r/issues/15667'), true); + t('…so a declined number costs exactly two reads and never more', closedCard.calls.length, 2); + + // --- UNDETERMINED is its own answer: never clean, never an accusation. + battery('UNDETERMINED is its own answer: never clean, never an accusation.'); + const unreadableThread = await run('Closes #15845', { '/repos/o/r/issues/15845/comments': new Error('boom') }); + t('a thread that could not be read is NOT a finding', unreadableThread.verdict.exit, EXIT_CLEAN); + t('…and it is warned about rather than folded into the clean answer', text(unreadableThread.verdict).includes('::warning::UNDETERMINED'), true); + t('…and the warning names the branch it could not prove anything about', text(unreadableThread.verdict).includes(HEAD), true); + + const unreadableNumber = await run('Closes #15845', { '/repos/o/r/issues/15845/comments': [] }); + t('a number whose own read fails is UNDETERMINED, not unclaimed', unreadableNumber.verdict.exit, EXIT_CLEAN); + t('…and says so', text(unreadableNumber.verdict).includes('UNDETERMINED'), true); + t('the page walk is capped, so an endless thread cannot hang the job', MAX_COMMENT_PAGES > 0 && MAX_COMMENT_PAGES <= 20, true); + + // ⭐ The MEASURED specimen, live on this very card: a dispatch claim that + // writes its branch on the `Claim:` LINE itself, after a separator, with no + // `Branch:` directive under it. `CLAIM_COMMENT_MARKER` matches it, the + // shipped branch reader yields zero branches, and `claimGovernance` calls it + // MALFORMED. ⛔ A blocking gate must not read that as "no claim": it would red + // a card that really is claimed, on this very branch, and the executor of a + // dispatched card ⛔ may not post a second claim to clear it. + const malformed = await run('Closes #15845', { + '/repos/o/r/issues/15845/comments': [{ body: `Claim: session_0deadbeef · ${HEAD}\nClause-②: no` }], + '/repos/o/r/issues/15845': { number: 15845, state: 'open' }, + }); + t('a claim whose branch cannot be PARSED is UNDETERMINED, ⛔ never a finding', malformed.verdict.exit, EXIT_CLEAN); + t('…and the warning says the `Branch:` line is what is missing', text(malformed.verdict).includes('no parseable `Branch:` line'), true); + t('…and it is an UNDETERMINED warning, never a silent green', text(malformed.verdict).includes('::warning::UNDETERMINED'), true); + + // --- Wiring absent: never clean, never an accusation. + battery('Wiring absent: never clean, never an accusation.'); + const unwired = judge(readPrContext({})); + t('no context at all exits NOT MEASURED', unwired.exit, EXIT_NOT_WIRED); + t('NOT MEASURED says it judged nothing', text(unwired).includes('judged nothing'), true); + t('NOT MEASURED does not read as a clean board', text(unwired).includes('✓'), false); + t('an empty repo slug is NOT WIRED, never a request to /repos//…', judge(readPrContext({ PR_NUMBER: '1', GITHUB_REPOSITORY: '' })).exit, EXIT_NOT_WIRED); + t('…and the reason names the variable', text(judge(readPrContext({ PR_NUMBER: '1', GITHUB_REPOSITORY: '' }))).includes('GITHUB_REPOSITORY'), true); + t('an empty token is NOT WIRED', judge(readPrContext({ PR_NUMBER: '1', GITHUB_REPOSITORY: 'o/r', GITHUB_TOKEN: '' })).exit, EXIT_NOT_WIRED); + t( + 'a missing head ref is NOT WIRED — the head branch IS the question, so a blank one cannot be judged', + judge(readPrContext({ PR_NUMBER: '1', GITHUB_REPOSITORY: 'o/r', GITHUB_TOKEN: 't', PR_BODY: 'Closes #2' })).exit, + EXIT_NOT_WIRED, + ); + t('…and it says which variable, so the fix is one line', text(judge(readPrContext({ PR_NUMBER: '1', GITHUB_REPOSITORY: 'o/r', GITHUB_TOKEN: 't' }))).includes('PR_HEAD_REF'), true); + t('PR_NUMBER is read by PRESENCE, so an empty one is still a wired witness', readPrContext({ PR_NUMBER: '', GITHUB_REPOSITORY: 'o/r', GITHUB_TOKEN: 't', PR_HEAD_REF: HEAD })?.number, ''); + t('an absent body reads as the empty string', readPrContext({ PR_NUMBER: '7', GITHUB_REPOSITORY: 'o/r', GITHUB_TOKEN: 't', PR_HEAD_REF: HEAD })?.body, ''); + t('an unset environment is not wired', readPrContext({}), null); + t('the NOT MEASURED text carries a runnable local spelling', text(unwired).includes('PR_HEAD_REF=claude/issue-123-slug'), true); + + // --- The merge-queue leg, asserted rather than assumed. + battery('The merge-queue leg, asserted rather than assumed.'); + const queueEnv = { + MERGE_GROUP_HEAD_REF: 'refs/heads/gh-readonly-queue/main/pr-900-abcdef1234567890abcdef1234567890abcdef12', + GITHUB_REPOSITORY: 'o/r', + GITHUB_TOKEN: 't', + }; + const queueCtx = readPrContext(queueEnv); + t('a merge_group ref alone is a WIRED context', queueCtx?.source, 'merge_group'); + t('…and the PR number is read by the shipped queue-ref parser, not a second regex', queueCtx?.number, String(pullNumberFromQueueRef(queueEnv.MERGE_GROUP_HEAD_REF))); + t('a base branch containing a slash is still read (the shipped parser\'s own lesson)', pullNumberFromQueueRef('refs/heads/gh-readonly-queue/release/v5/pr-7-abcdef1'), 7); + t('a ref that is NOT a queue ref is NOT WIRED, never a PR number invented from a branch name', judge(readPrContext({ ...queueEnv, MERGE_GROUP_HEAD_REF: 'refs/heads/pr-12-abcdef1' })).exit, EXIT_NOT_WIRED); + + const queueApi = fakeApi({ + '/repos/o/r/pulls/900': { number: 900, body: 'Closes #15845', head: { ref: HEAD } }, + '/repos/o/r/issues/15845/comments': [claim(HEAD)], + }); + const queueGreen = judge(await collect(queueCtx, queueApi.api)); + t('the queue leg reads the SAME PR body and goes green on a correct claim', queueGreen.exit, EXIT_CLEAN); + t('…and says it is a merge-queue build, so a reader can tell the legs apart', text(queueGreen).includes('merge-queue build'), true); + + const queueRedApi = fakeApi({ + '/repos/o/r/pulls/900': { number: 900, body: 'Closes #15845', head: { ref: HEAD } }, + '/repos/o/r/issues/15845/comments': [claim(OTHER)], + '/repos/o/r/issues/15845': { number: 15845, state: 'open' }, + }); + const queueRed = judge(await collect(queueCtx, queueRedApi.api)); + t('…and the queue leg reaches the SAME finding as the pull_request leg', queueRed.exit, EXIT_UNCLAIMED); + t('…with the same remedies, because it is the same verdict layer', text(queueRed).includes('RENAMED or RE-CREATED'), true); + + const queueBroken = judge(await collect(queueCtx, fakeApi({}).api)); + t('a queue ref whose pull request cannot be read is NOT MEASURED, never clean and never a finding', queueBroken.exit, EXIT_NOT_WIRED); + + // --- The wiring itself. A gate whose workflow step is deleted or whose + // trigger loses a leg is not a weaker gate, it is a silent one. + battery('The wiring itself. A gate whose workflow step is deleted or whose'); + const wiringPath = join(ROOT, WIRING_WORKFLOW); + const wiring = existsSync(wiringPath) ? readFileSync(wiringPath, 'utf8') : ''; + t('the wiring workflow exists', wiring !== '', true); + t('the wiring workflow runs this script', wiring.includes('node scripts/check-closing-target-claim.mjs'), true); + t('the wiring declares the check-run name this file pins', wiring.includes(CHECK_CONTEXT_NAME), true); + t('the wiring subscribes to body edits, so dropping the keyword can go green', /types:\s*\[[^\]]*\bedited\b[^\]]*\]/.test(wiring), true); + t('the wiring subscribes to `synchronize`, so a re-pushed branch is re-judged', /types:\s*\[[^\]]*\bsynchronize\b[^\]]*\]/.test(wiring), true); + t('the wiring takes the merge_group leg — the queue claim is wiring, not prose', /^\s{2}merge_group:\s*$/m.test(wiring), true); + t('the wiring passes the head ref, without which nothing can be judged', /PR_HEAD_REF:/.test(wiring), true); + t('the wiring passes the merge-queue head ref', /MERGE_GROUP_HEAD_REF:/.test(wiring), true); + t('everything reaches the script through `env:`, never through shell interpolation', /env:[\s\S]{0,400}?PR_BODY:/.test(wiring), true); + const wiringCommands = wiring.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); + t('the guard job invokes no package manager (it needs node and nothing else)', /\b(pnpm|corepack|yarn|npm)\b/.test(wiringCommands), false); + + // --- The predicate sources this gate reuses must still be there to reuse. + battery('The predicate sources this gate reuses must still be there to reuse.'); + for (const source of PREDICATE_SOURCES) t(`the predicate source ${source} exists`, existsSync(join(ROOT, source)), true); + t('both predicate sources are declared, so a move reddens here', PREDICATE_SOURCES.length, 2); + + // The floor runs BEFORE the verdict below, so a success line can only be + // printed by a run in which every declared battery registered its cases. + for (const message of batteryFloorFailures()) cases.push([message, false, true]); + + let failedCount = 0; + for (const [name, actual, expected] of cases) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (!ok) failedCount++; + console.log(` ${ok ? '✓' : '✗'} ${name}${ok ? '' : ` (got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)})`}`); + } + if (failedCount) { + console.error(`✗ check-closing-target-claim self-test: ${failedCount} of ${cases.length} case(s) failed.`); + process.exit(1); + } + console.log(`✓ check-closing-target-claim self-test: ${cases.length} cases pass.`); + selfTestReachedVerdict = true; +} + +// The basename comparison, as in the siblings: this file is imported by nothing +// today, but a future importer must not trigger a judgment as a side effect. +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + await selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-closing-target-claim self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + } else { + const ctx = readPrContext(process.env); + const collected = ctx === null || ctx.wired === false ? ctx : await collect(ctx, githubApi(ctx.token)); + const result = judge(collected); + const emit = result.exit === EXIT_CLEAN ? console.log : console.error; + for (const line of result.lines) emit(line); + process.exit(result.exit); + } +} diff --git a/scripts/check-partof-closing-keyword.mjs b/scripts/check-partof-closing-keyword.mjs index f58046e49e..bd5f8ab12f 100644 --- a/scripts/check-partof-closing-keyword.mjs +++ b/scripts/check-partof-closing-keyword.mjs @@ -72,9 +72,9 @@ * why the wiring workflow subscribes to the `edited` activity type — an * author who rewords the sentence gets a fresh event with a fresh payload * and a green run, with no push and no re-run. The identical trigger set - * and the identical reason are already in the duplicate-fix guard, the - * repo's other PR-body-scoped blocking check; this follows it rather than - * inventing a second shape. + * and the identical reason are already in the duplicate-fix guard, one of + * this repo's other PR-body-scoped blocking checks; this follows it rather + * than inventing a second shape. * * The residual hole is named rather than hidden: `rerun_failed_jobs` on a run * whose body has since been fixed replays the stale body and stays red. The