From d1ab6dc5d40f37528445097f25258f4d39ebedd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 06:46:32 +0000 Subject: [PATCH 1/5] ci: wire the issue-citation verdict and the merged-result probe to CI Two gates were registered in the root manifest and called by zero workflows: `scripts/check-issue-citations.mjs` (delivered by #18223) and `scripts/check-merged-result.mjs` (delivered by #18338). Both cards' file surfaces excluded `.github/workflows/**`, so both devs correctly stopped and filed the wiring instead of widening. Three entry points, two lanes, two deliberately opposite postures: - `lint.yml` / `Lint & Repo Gates`: the DIFF-SCOPED citation verdict, blocking. Two commands in one step -- the manifest alias (which is the checker's own `--self-test` and nothing else) and then the live diff run. Wiring the alias alone would run the self-test twice and scan nothing. - `lint.yml` / `Lint & Repo Gates`: `check:merged-result`, blocking. Offline, no credential, sub-second. - `half-state-patrol.yml`: `--census`, REPORT-ONLY, four times a day. The census verdict moves on a motionless tree -- three numbers in its own control set went 404 in four days with no change here -- so a tree-wide blocking verdict would red a repository nobody touched. The lint job gains `issues: read`: an explicit `permissions:` block sets every unnamed scope to `none`, and a blocking gate must not depend on repository visibility for its transport. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .github/workflows/half-state-patrol.yml | 82 +++++++++++++++++++++++++ .github/workflows/lint.yml | 71 +++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/.github/workflows/half-state-patrol.yml b/.github/workflows/half-state-patrol.yml index d725b5eefc4..2b925e29a09 100644 --- a/.github/workflows/half-state-patrol.yml +++ b/.github/workflows/half-state-patrol.yml @@ -153,6 +153,11 @@ on: # as the row above, one file along: a step whose script can change without # this trigger firing is a step whose PR-time proof is a coincidence. - 'scripts/pm/sweep-closed-cards.mjs' + # The citation census this workflow also calls (#18224). Same reasoning as + # the two rows above, one file along: a step whose script can change + # without this trigger firing is a step whose PR-time proof is a + # coincidence. + - 'scripts/check-issue-citations.mjs' - '.github/workflows/half-state-patrol.yml' # Least privilege: this job reads the repo and writes exactly one issue BODY. @@ -459,6 +464,83 @@ jobs: echo '' } >> "$GITHUB_STEP_SUMMARY" + - name: Census the repo's issue citations + # #17512's gate, wired here by #18224 — the REPORT-ONLY half, and the + # posture is a ruling, not a preference. `--census` judges every + # citation in the gate's declared surfaces (the published release pages + # and package source docblocks), which is ~2,785 unresolvable sites on a + # tree nobody touched, and its verdict is NOT a function of this tree: + # #16783, #16786 and #16787 were measured RESOLVING on 2026-09-10 and + # 404 on 2026-09-14 with no change to this repository. That is exactly + # the shape this workflow exists for — a fact about a live shared board, + # not about whichever change happens to run CI next — so it belongs on + # the patrol lane and ⛔ NEVER on a blocking one. The DIFF-scoped half of + # the same gate is the blocking one and lives in `lint.yml`; the two + # postures are opposite on purpose and ⛔ neither moves to the other's + # lane. + # + # WHERE THE REPORT GOES: this run's step summary and job log, plus one + # `::warning::` carrying the site count. ⛔ NOT the anchor issue — that + # body is owned end-to-end by `check-half-states.mjs`'s generator, and a + # second writer is how half of a generated body goes stale. + # HOW OFTEN: on this workflow's schedule — four times a day, six hours + # apart — plus any `workflow_dispatch`, plus the `pull_request` runs the + # paths filter above admits. + # WHAT IT COSTS AND WHO PAYS: the census enumerates the whole board once + # (159 requests on this repo at the time of writing, cursor-paginated — + # the alternative is one request per distinct number). It is paid by + # THIS repository's own `secrets.GITHUB_TOKEN` core quota, the same + # 5,000/hour this job already draws the live sweep from: ~636 + # requests/day at four runs, under half a percent of a single hour's + # allowance. ⛔ No PAT, no cross-repo credential — the file's own rule. + # + # ⛔ Gated on the repository NAME, for the reason the closed-card sweep + # above states: this script is objectstack-only until a sibling has a + # copy, and a verbatim copy of this workflow elsewhere must SKIP rather + # than fail on a missing file. + # + # ⛔ This step never fails the job, whatever the census returns — + # findings are not a failure condition here, and neither is a census + # that could not read the board: that is an alarm (`::error::`), not a + # gate. Placed AFTER the anchor write on purpose, unlike the closed-card + # sweep: the anchor is this patrol's product, this job has a + # 15-minute timeout, and a report-only reading must never be able to + # starve the thing the workflow exists to deliver. + if: github.repository == 'objectstack-ai/objectstack' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + node scripts/check-issue-citations.mjs --census \ + > "$RUNNER_TEMP/issue-citations.md" 2> "$RUNNER_TEMP/issue-citations.err" + code=$? + set -e + # Captured with NO pipe in between, for the reason the two steps above + # state at length: piped, `$?` is the pipe's status and a red run and a + # green one read the same. + { + echo "### Issue-citation census — exit $code (report-only)" + echo + echo '```' + cat "$RUNNER_TEMP/issue-citations.md" 2>/dev/null || echo '(no report produced)' + echo '```' + echo + echo '
stderr' + echo + echo '```' + cat "$RUNNER_TEMP/issue-citations.err" 2>/dev/null || true + echo '```' + echo + echo '
' + } >> "$GITHUB_STEP_SUMMARY" + cat "$RUNNER_TEMP/issue-citations.err" >&2 || true + if [ "$code" != "0" ]; then + echo "::error::issue-citation census exited $code — the board was NOT read, so this run says nothing about whether unresolvable citations are accumulating. A census that could not run is not a clean census. See this run's summary." + else + sites=$(sed -n 's/^.*census: \([0-9][0-9]*\) unresolvable citation site(s).*$/\1/p' "$RUNNER_TEMP/issue-citations.md" | tail -1) + echo "::warning::issue-citation census: ${sites:-unknown} unresolvable citation site(s) in the declared surfaces. Report-only — the blocking half judges only what a change ADDS." + fi + - name: Fail the run if the sweep could not run # LAST, on purpose: the anchor is updated with the did-not-run report # BEFORE the job goes red. Land the truth, then raise the alarm — a run diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1818c40c42f..bf74c8b1142 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -184,6 +184,14 @@ jobs: # `scripts/report-unmeasured-gate-tail.mjs`. Nothing else in this job # reads it, and no step writes anything anywhere. actions: read + # ⭐ Added by #18224, for the issue-citation step far below: it resolves + # the numbers THIS change cites against the board. Read-only and one + # scope wide — no issue, comment, label or assignee is ever written. It + # is spelled even though this board is PUBLIC, because an explicit + # `permissions:` block sets every unnamed scope to `none`, and a BLOCKING + # gate whose transport depends on repository visibility is a gate that + # goes red on a settings change no file here can assert. + issues: read steps: - name: Checkout repository @@ -4616,6 +4624,69 @@ jobs: - name: workspace manifest dependency graph has no cycle run: pnpm check:workspace-manifest-cycles + # Merged-result budget probe, wired by the card that wired the citation + # gate below (#18224). `node scripts/check-merged-result.mjs --self-test` + # IS the whole gate and is exactly what the root manifest registers as + # `check:merged-result`; the probe landed in #18338 with the file surface + # of its own card excluding `.github/workflows/**`, so until this step no + # workflow named it and its self-test ran nowhere. Offline: no build, no + # network, no credential, sub-second. Its defect class is a MATCHING RULE, + # which a clean tree cannot tell from a rule that stopped matching — the + # self-test is the only instrument that can, and an instrument nobody runs + # is silence. + - name: Merged-result probe self-test + run: pnpm check:merged-result + + # Issue citations (#17512, wired by #18224). TWO commands, and the split + # is the point: + # + # `pnpm check:issue-citations` the checker's own + # `--self-test` — offline, + # no board, no credential. + # `node scripts/check-issue-citations.mjs` the DIFF-SCOPED verdict — + # the live run, judging only + # the citations THIS change + # adds. + # + # ⛔ The manifest alias is NOT the verdict. It runs `--self-test` and + # nothing else — the shape every credential-needing gate in this manifest + # uses (`check:pm-half-states`, `check:pm-closed-card-sweep`), because a + # live mode needs a board and a credential. Wiring the alias ALONE would + # run the self-test twice and scan nothing. Both spellings are here on + # purpose, self-test first: `check-self-test-wired` requires CI to run the + # self-test of every script CI runs, and a verdict from a checker whose + # own cases were never exercised is a verdict about nothing. + # + # ⭐ WHY DIFF-SCOPED AND NOT TREE-WIDE, which is the whole ruling. The + # standing population is ~2,785 unresolvable citation sites, and the + # predicate is NOT a function of this tree: #16783, #16786 and #16787 were + # measured RESOLVING on 2026-09-10 and 404 on 2026-09-14, with no change + # to this repository. A tree-wide blocking verdict therefore reds a + # MOTIONLESS tree because somebody else deleted an issue, and fails a PR + # for a change its author did not make and cannot repair. The diff-scoped + # half is the part an author owns, it is small, and it is the only thing + # that stops 2,785 becoming 3,000. The tree-wide reading is a `--census` + # and it runs REPORT-ONLY in `half-state-patrol.yml`, four times a day — + # never here, and never blocking. ⛔ Do not "upgrade" this step to + # `--census`. + # + # The diff base is `git merge-base origin/main HEAD`, which is why this + # job checks out at `fetch-depth: 0` (the checkout step above says so for + # its own reason). On a `push` to main the merge base IS the head, so the + # added set is empty, the run makes ZERO requests and exits 0 before it + # asks the board anything. + # + # ⛔ A failed read is never a pass: an unreadable board exits + # PREREQUISITE NOT MET rather than reporting a clean tree. + - name: Issue citations this change adds resolve on the board + env: + # The board read is one GET per distinct number the change cites + # (none at all when it cites none), against a PUBLIC board. Spelled + # anyway: the gate uses the token when it finds one, and an + # unauthenticated runner shares one IP's 60 requests/hour. + GITHUB_TOKEN: ${{ github.token }} + run: pnpm check:issue-citations && node scripts/check-issue-citations.mjs + # #15149. Grep-level guard for the defect this very repo just shipped: an # unquoted `- name:` step name containing ` #` is silently truncated by # YAML at that point (a space + hash starts a comment inside a plain From 02559f58578cd9ad363bb69dc7ffd05a4d86ceb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 06:48:04 +0000 Subject: [PATCH 2/5] =?UTF-8?q?test(ci):=20ABLATION=20LEG=201=20of=202=20?= =?UTF-8?q?=E2=80=94=20add=20an=20unresolvable=20citation=20so=20CI=20must?= =?UTF-8?q?=20go=20red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary, and reverted by the next commit on this branch. The card's acceptance asks for the red/green measurement to be taken ON THE CI SIDE, not only on the gate side (#18223 already did the gate-side pair with disk evidence). This commit is the red leg: one citation naming a number beyond this board's allocation frontier, inside a declared surface (`packages/**/src/**/*.ts`, comment-prose projection). Expected: the `Issue citations this change adds resolve on the board` step in `Lint & Repo Gates` exits 2 and the job goes red. The next commit removes the line and the same step must go green. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- packages/types/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0235d4e7d2e..2ac9ba33397 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -5,6 +5,7 @@ export * from './degraded-boot.js'; // gate (plugin-security) and the owner-verification boot diagnostic // (plugin-auth) both read — see the module doc for why it must be one. export * from './email-verified.js'; +// [#999999] OS_ABLATION_18224 — a citation naming a number this board never minted. export * from './env.js'; export * from './error-leak.js'; // [#17681] The SIBLING question, kept deliberately separate: `error-leak.js` From 1195a182570acedf2a80d217431c2d639e9f0c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 06:58:55 +0000 Subject: [PATCH 3/5] fix(ci): the citation gates need `pull-requests: read`, measured on a real runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census step landed on a runner in this PR's own patrol run and reported 3,628 unresolvable citation sites. A full-scope read of the same tree reports 2,168. The difference is 1,460 — EXACTLY the `resolves-as-pull-request` tally, so every citation naming a PR number was classified as a number the board never had. Cause: `GET /repos/{owner}/{repo}/issues` omits pull requests unless the token holds `pull-requests`, and an explicit `permissions:` block sets every unnamed scope to `none`. The same call is how the gate reads the allocation frontier (`?per_page=1&sort=created&direction=desc`), so an understated frontier turns later numbers into `never-issued` as well. Both lanes take the row, read-only: - `half-state-patrol.yml`: a report-only reading wrong by 67% is still a machine-readable surface telling a lie. - `lint.yml`: on the BLOCKING step it is worse than a wrong number — it is a false red on a correct citation. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .github/workflows/half-state-patrol.yml | 12 ++++++++++++ .github/workflows/lint.yml | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.github/workflows/half-state-patrol.yml b/.github/workflows/half-state-patrol.yml index 2b925e29a09..22d076a161b 100644 --- a/.github/workflows/half-state-patrol.yml +++ b/.github/workflows/half-state-patrol.yml @@ -164,9 +164,21 @@ on: # `issues: write` is the narrowest scope GitHub offers for that edit; the job # never uses it for labels, comments, assignees or state, and the sweeper it # calls is read-only against the API by construction. +# +# `pull-requests: read` is READ-ONLY and buys one thing, for the citation +# census step only — MEASURED on run 35495222460, this workflow's own +# pull_request run WITHOUT this row: the census reported 3,628 unresolvable +# citation sites where a full-scope read of the same tree reported 2,168, and +# the difference is 1,460 — EXACTLY the `resolves-as-pull-request` tally. +# `GET /repos/{owner}/{repo}/issues` answers with the pull requests omitted +# unless this scope is held, so every citation naming a PR number was reported +# as a number the board never had. A report-only reading that is wrong by 67% +# is still a machine-readable surface telling a lie. ⛔ Do not drop this row +# as tidying, and ⛔ do not widen it to `write`: nothing here writes a PR. permissions: contents: read issues: write + pull-requests: read # One patrol at a time. A scheduled run overlapping a manual dispatch would have # two runs racing to rewrite the same body, and the loser's findings would vanish diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bf74c8b1142..522cd52eb96 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -192,6 +192,19 @@ jobs: # gate whose transport depends on repository visibility is a gate that # goes red on a settings change no file here can assert. issues: read + # ⭐ AND `pull-requests`, which is NOT redundant with the row above — + # MEASURED, on run 35495222460 of `half-state-patrol.yml`, whose token + # carries `issues` and NOT this scope: its census reported 3,628 + # unresolvable citation sites where a full-scope read of the same tree + # reported 2,168, and the difference is 1,460 — EXACTLY the + # `resolves-as-pull-request` tally. `GET /repos/{owner}/{repo}/issues` + # answers with the pull requests omitted, so every citation naming a PR + # number reads as a number the board never had. On THIS blocking step + # that is a false RED on a correct citation, and the frontier probe + # (`?per_page=1&sort=created&direction=desc`) is read from the same + # response — so an understated frontier turns later numbers into + # `never-issued` too. ⛔ Do not drop this row as tidying. + pull-requests: read steps: - name: Checkout repository From 6d1272b3850e5c0e756745dc6486d20aa330653d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 06:59:41 +0000 Subject: [PATCH 4/5] =?UTF-8?q?test(ci):=20ABLATION=20LEG=202=20of=202=20?= =?UTF-8?q?=E2=80=94=20remove=20the=20unresolvable=20citation,=20CI=20must?= =?UTF-8?q?=20go=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset half of the CI-side ablation the card asks for. `packages/types/src/ index.ts` returns to the blob it carried before leg 1 (`0235d4e7d2ebe529c7101b5e1e004597b30c4554`), marker count 1 back to 0, and this branch's delta against `main` is again the two workflow files alone. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- packages/types/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2ac9ba33397..0235d4e7d2e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -5,7 +5,6 @@ export * from './degraded-boot.js'; // gate (plugin-security) and the owner-verification boot diagnostic // (plugin-auth) both read — see the module doc for why it must be one. export * from './email-verified.js'; -// [#999999] OS_ABLATION_18224 — a citation naming a number this board never minted. export * from './env.js'; export * from './error-leak.js'; // [#17681] The SIBLING question, kept deliberately separate: `error-leak.js` From e2220c59a8ea9b67c6c29edc7717a8ed8bcb45fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:26:11 +0000 Subject: [PATCH 5/5] fix(ci): the citation gate judges the merge group's own diff, not the queue ahead of it This step's first queue build ejected the PR that added it, and the reason was neither the tree nor a crash: `exit 2` is this gate's own FINDINGS code, and the findings were real citations written by somebody else. MEASURED on queue entry `a7109d1f08`. A queue entry is built on the GROUP's base, which carries the entries AHEAD of it in the queue and has not landed on `main` yet. `merge-base origin/main HEAD` therefore lands at the PUBLISHED tip: `231283a6e` at 14:30:14Z, while the group's base `8271c81425` reached `main` only at 14:47:59Z. Everything between the two read as "added by this change" -- 15 file(s) / 16 citations judged, 3 unresolvable, and all three written by the two entries ahead: `#6361` twice from `ada701220` (#19364), `#18003` from `8271c81425` (#19363). Against the group's own base the same tree judges 0 file(s). The Governed Surface Queue Guard, in the same build, read `merge_group.base_sha` and correctly saw 1 commit and 178 changed lines. Two halves, and the second is not cosmetic: 1. `lint.yml` declares the base -- `OS_GATE_MERGE_GROUP_BASE_SHA`, the name and the expression this file already uses for that fact. It renders empty on `pull_request` and `push`, where the ref guesses are CORRECT and are kept: a PR's merge ref already contains the main it was computed against. The step is not `if:`-skipped on `merge_group` -- this file asserts that every gate step here runs there. 2. The gate verifies the base resolves before handing it to `git diff`. It did not: an unresolvable `--base` threw `fatal: bad object` and exited 1, a failed read wearing a code that is neither the clean answer, the findings answer, nor the refusal. It now refuses with PREREQUISITE NOT MET (exit 3) and names every spelling tried and what to pass instead. Half 1 alone is inert -- the pre-change gate ignores the variable entirely -- and half 1 is what makes an unverified base reachable, so both are required. Firing controls, on the real queue tree with `origin/main` pinned to the published main of 14:30:14Z: ref guess -> exit 2 with the three findings, reproducing the ejection; declared base -> exit 0; declared base absent from the checkout -> exit 3; `--base` absent -> exit 3 (was: uncaught throw, exit 1). `--self-test` covers all of it: 66 -> 73 cases, 7 batteries, and the `diff-scope` battery floor moves 6 -> 13 so the new cases cannot stop running unnoticed. Also corrects the gate docblock sentence this wiring falsifies ("Neither is installed here"). Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- .github/workflows/lint.yml | 52 ++++++++++-- scripts/check-issue-citations.mjs | 132 +++++++++++++++++++++++++++--- 2 files changed, 163 insertions(+), 21 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7c4f14cac20..08842a67b79 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4705,14 +4705,45 @@ jobs: # never here, and never blocking. ⛔ Do not "upgrade" this step to # `--census`. # - # The diff base is `git merge-base origin/main HEAD`, which is why this - # job checks out at `fetch-depth: 0` (the checkout step above says so for - # its own reason). On a `push` to main the merge base IS the head, so the - # added set is empty, the run makes ZERO requests and exits 0 before it - # asks the board anything. - # - # ⛔ A failed read is never a pass: an unreadable board exits - # PREREQUISITE NOT MET rather than reporting a clean tree. + # THE DIFF BASE, per event, and the merge_group row is the whole of + # #19259's ejection: + # + # pull_request `git merge-base origin/main HEAD`. CORRECT and left + # alone: the checked-out merge ref already CONTAINS the + # main it was computed against, so the merge base IS that + # main and nothing newer can leak into the added set. + # push (main) the merge base IS the head, so the added set is empty, + # the run makes ZERO requests and exits 0 before it asks + # the board anything. + # merge_group ⭐ the group's own `base_sha`, declared below. A queue + # entry is built on the GROUP's base, which carries the + # entries AHEAD of it in the queue and has NOT landed on + # `main` yet, so the published `origin/main` this runner + # fetched is BEHIND that base and the ref guess lands at + # the published tip — attributing every entry ahead in the + # queue to THIS change. + # + # MEASURED, on this step's own first queue build (entry `a7109d1f08`, + # 2026-09-20): the ref guess resolved to the published main of 14:30Z and + # judged 15 file(s) / 16 citations, 3 unresolvable — `#6361` twice from + # `ada701220` (#19364) and `#18003` from `8271c81425` (#19363), the two + # entries ahead in the queue, both of which landed on `main` only at + # 14:36:53Z and 14:47:59Z. The step exited 2 (its findings code) and the + # queue ejected the PR for citations its author never wrote. Against + # `base_sha` the same tree judges 0 file(s). + # + # ⛔ Do NOT `if:`-skip this step on `merge_group` instead: this file's + # header asserts that every gate step here runs on merge_group, and a + # blocking gate turned off on the one event that guards what is about to + # reach `main` is a gate that is not one. Both halves of the repair are + # this: the base above, and the gate's own refusal below. + # + # `fetch-depth: 0` is what makes either spelling resolvable (the checkout + # step above says so for its own reason). + # + # ⛔ A failed read is never a pass: an unreadable board — and now an + # unresolvable base, which used to throw an unclassified exit code — + # exits PREREQUISITE NOT MET rather than reporting a clean tree. - name: Issue citations this change adds resolve on the board env: # The board read is one GET per distinct number the change cites @@ -4720,6 +4751,11 @@ jobs: # anyway: the gate uses the token when it finds one, and an # unauthenticated runner shares one IP's 60 requests/hour. GITHUB_TOKEN: ${{ github.token }} + # Renders the group's base on `merge_group` and EMPTY on every other + # event, where the gate keeps the `origin/main` / `main` guesses. Same + # name, same expression, same fact as the gate-family selector above + # — one fact, one spelling. + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} run: pnpm check:issue-citations && node scripts/check-issue-citations.mjs # #15149. Grep-level guard for the defect this very repo just shipped: an diff --git a/scripts/check-issue-citations.mjs b/scripts/check-issue-citations.mjs index e7a3516c550..90324bb885d 100644 --- a/scripts/check-issue-citations.mjs +++ b/scripts/check-issue-citations.mjs @@ -7,6 +7,10 @@ * * node scripts/check-issue-citations.mjs # judge what this change ADDS * node scripts/check-issue-citations.mjs --base # ...relative to + * + * A runner declares the base instead, through `OS_GATE_MERGE_GROUP_BASE_SHA` + * -- see `baseSpellings` below for the `merge_group` reading that makes it the + * only correct answer there. * node scripts/check-issue-citations.mjs --census # the whole declared surface * node scripts/check-issue-citations.mjs --list # extraction only, no network * node scripts/check-issue-citations.mjs --json # machine-readable @@ -138,8 +142,10 @@ * report-only and scheduled -- the right posture for a * reading whose verdict a third party can change. * - * Neither is installed here. That is a DECLARED gap, recorded in the PR body and - * the report, not an oversight. + * Both are installed (#18224): the diff-scoped verdict in `lint.yml`'s + * `Lint & Repo Gates` job, the `--census` in `half-state-patrol.yml`. The + * verdict step also declares the base -- see `baseSpellings` below for why a + * `merge_group` build cannot be left to guess one. * * ## The local route -- why this gate re-execs itself * @@ -601,14 +607,91 @@ export function addedLines(base, root) { return out; } -/** The merge-base this repo's PRs are judged against. */ -export function defaultBase(root) { - for (const ref of ['origin/main', 'main']) { - try { - return execFileSync('git', ['merge-base', ref, 'HEAD'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); - } catch { /* try the next spelling */ } +/** + * Every diff-base spelling a run may use, in order, each carrying the reason it + * is on the list -- the refusal below prints them, so a reader who acts on it + * looks where the run actually looked. + * + * ⭐ A runner that DECLARES a base replaces the ref guesses entirely, and on a + * `merge_group` build that is not a convenience, it is the only correct answer. + * A queue entry is built on the GROUP's base, which carries the entries AHEAD + * of it in the queue and has NOT landed on `main` yet; the published + * `origin/main` the runner fetched is therefore BEHIND that base, and + * `merge-base origin/main HEAD` lands at the published tip. Everything between + * the two -- other people's PRs -- then reads as "added by this change". + * + * MEASURED on the ejection this spelling exists for: on queue entry + * `a7109d1f08` the ref guess resolved to the published main of 14:30Z and + * judged 15 file(s) / 16 citations, 3 of them unresolvable and every one of + * those written by the two entries ahead in the queue -- `#6361` twice from + * `ada701220`, `#18003` from `8271c81425`, both landing on `main` AFTER that + * checkout (14:36:53Z and 14:47:59Z). The declared base judged 0 file(s). So + * the ref guess failed a PR for citations its author did not write, which is + * the same defect class this gate's own diff scoping exists to prevent. + * + * ⛔ `pull_request` and `push` keep the ref guesses, deliberately: there the + * checked-out merge ref already CONTAINS the main it was computed against, so + * the merge base IS that main and nothing newer can leak into the added set. + * + * The variable is the name `lint.yml` already uses for this fact + * (`scripts/ci/select-gate-families.sh`) -- one fact, one spelling. It is + * deliberately NOT `PROXY_REARM_GUARD`'s own-name case: a re-exec guard is this + * PROCESS's state, which a sibling instrument must never answer for, while a + * group's `base_sha` is a fact about the build that every reader of it shares. + */ +export function baseSpellings({ base = null, env = process.env } = {}) { + if (base) return [{ ref: base, verbatim: true, why: 'the `--base` argument' }]; + const declared = (env.OS_GATE_MERGE_GROUP_BASE_SHA ?? '').trim(); + if (declared) return [{ ref: declared, verbatim: true, why: 'OS_GATE_MERGE_GROUP_BASE_SHA -- `github.event.merge_group.base_sha`' }]; + return [ + { ref: 'origin/main', verbatim: false, why: 'the remote-tracking main a CI checkout fetches' }, + { ref: 'main', verbatim: false, why: 'a local main, for a clone that tracks no remote' }, + ]; +} + +/** @returns {string|null} what git printed, or null when the command refused. */ +function gitLine(root, args) { + try { + return execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; + } catch { return null; } +} + +/** + * The base a diff-scoped run is judged against, plus every spelling tried. + * + * ⛔ It never returns a base it did not VERIFY resolves to a commit in this + * checkout. An unverified base reaches `git diff` as-is, which answers + * `fatal: bad revision` and THROWS -- an uncaught exception whose exit code is + * none of this gate's three answers, so a failed read arrives wearing an + * unclassified number. "could not resolve" and "resolves" are not the same + * answer, so an unresolvable spelling answers `{ base: null }` here and the + * caller refuses loudly with `EXIT_PREREQUISITE_NOT_MET`. + */ +export function resolveDiffBase({ root = process.cwd(), base = null, env = process.env } = {}) { + const tried = baseSpellings({ base, env }); + for (const spelling of tried) { + /* A declared base is taken VERBATIM -- it already IS the fork point. A ref + * guess goes through `merge-base`, because a branch TIP is not one. */ + const sha = spelling.verbatim + ? gitLine(root, ['rev-parse', '--verify', '--quiet', `${spelling.ref}^{commit}`]) + : gitLine(root, ['merge-base', spelling.ref, 'HEAD']); + if (sha) return { base: sha, used: spelling, tried }; } - return null; + return { base: null, used: null, tried }; +} + +/** + * The refusal text for a base that does not resolve. It names every spelling + * TRIED and what to pass instead: "no merge-base with `origin/main`" is a true + * sentence about a run that never looked at `origin/main` at all, and a reader + * who acts on it looks in the wrong place. + */ +export function unresolvedBaseMessage({ tried }) { + const spellings = tried.map((s) => `\`${s.ref}\` (${s.why})`).join(', '); + return `no diff base resolves to a commit in this checkout — tried ${spellings}. ` + + 'A diff-scoped run has no baseline to judge against. Pass one with `--base `, ' + + 'or set OS_GATE_MERGE_GROUP_BASE_SHA to a commit this checkout has, or fetch `main` ' + + '(a shallow clone carries no merge base).'; } /** @@ -709,9 +792,11 @@ function prerequisiteRefusal(message) { } export async function run({ root = process.cwd(), scope = 'diff', base = null, json = false, probeCause = false, ownerRepo = 'objectstack-ai/objectstack', strategy = 'auto' } = {}) { - const resolvedBase = scope === 'diff' ? (base ?? defaultBase(root)) : null; - if (scope === 'diff' && !resolvedBase) { - prerequisiteRefusal('no merge-base with `origin/main` or `main` — a diff-scoped run has no baseline to judge against.'); + let resolvedBase = null; + if (scope === 'diff') { + const attempt = resolveDiffBase({ root, base }); + if (!attempt.base) prerequisiteRefusal(unresolvedBaseMessage(attempt)); + resolvedBase = attempt.base; } const { rows, files } = collectCitations({ root, scope, base: resolvedBase }); @@ -810,7 +895,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ causes: 12, transport: 7, 'scope-contract': 12, - 'diff-scope': 6, + 'diff-scope': 13, 'live-corpus': 3, 'proxy-rearm': 10, }); @@ -995,6 +1080,27 @@ export async function selfTest() { const board = boardFromSets({ numbers: [100], pulls: [], frontier: 1000, source: 'stub' }); check(classifyCitation(after.rows[0], board, { ownerRepo: 'o/r' }).cause === CAUSE.ALLOCATED_BUT_ABSENT, 'the added citation must classify as unresolvable against a board that lacks it'); + + /* ⭐ THE BASE, both directions. The base decides WHICH diff is judged, so + * a wrong one does not fail loudly -- it judges somebody else's change + * and reports the answer as this one's. */ + const absent = '0'.repeat(40); + check(resolveDiffBase({ root: tmp, base }).base === base, + 'a resolvable explicit base must be used verbatim -- it already IS the fork point'); + check(resolveDiffBase({ root: tmp, base: absent }).base === null, + '⛔ a `--base` naming no commit here must answer null, NEVER be handed to `git diff` -- unverified, it throws, and an uncaught throw is a failed read wearing an unclassified exit code'); + const refusal = unresolvedBaseMessage(resolveDiffBase({ root: tmp, base: absent })); + check(refusal.includes(absent) && refusal.includes('--base ') && refusal.includes('OS_GATE_MERGE_GROUP_BASE_SHA'), + '...and the refusal must name the spelling that was TRIED and what to pass instead'); + check(resolveDiffBase({ root: tmp, env: { OS_GATE_MERGE_GROUP_BASE_SHA: base } }).base === base, + 'a runner-DECLARED merge-group base must become the base, so a queue build judges its own diff and not the entries ahead of it in the queue'); + check(resolveDiffBase({ root: tmp, env: { OS_GATE_MERGE_GROUP_BASE_SHA: absent } }).base === null, + '⛔ ...and a declared base this checkout does not have must REFUSE, never fall back to a ref guess that would silently judge a different diff'); + check(baseSpellings({ env: {} }).map((s) => s.ref).join() === 'origin/main,main' + && baseSpellings({ env: { OS_GATE_MERGE_GROUP_BASE_SHA: '' } }).map((s) => s.ref).join() === 'origin/main,main', + 'with nothing declared -- and on the events where that variable renders EMPTY -- the ref guesses are unchanged, so `pull_request` and `push` keep the base they already had'); + check(/if \(!attempt\.base\) prerequisiteRefusal\(unresolvedBaseMessage\(attempt\)\);/.test(readFileSync(SELF_PATH, 'utf8')), + 'structural: the null answer must reach `prerequisiteRefusal` -- exit 3, never 0 and never an uncaught throw'); } finally { rmSync(tmp, { recursive: true, force: true }); }