From 74f37531a5ebee541ebf67e6f0e5fcfd5bcc0416 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:16:43 +0000 Subject: [PATCH 1/5] ci(pm): check:pm-dispatch-gates runs the fast tier on pull_request and merge_group, both tiers on every other CI event The tier now follows GITHUB_EVENT_NAME under GITHUB_ACTIONS: the PR and merge-queue path gets --self-test --fast; schedule, push to main, workflow_dispatch and any unlisted event keep both tiers. --slow stays the manual override everywhere. The self-test drives each event and blanks the runner's own GITHUB_EVENT_NAME so it cannot leak into the cases. Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- scripts/pm/check-dispatch-gates.mjs | 116 +++++++++++++++++++++------- 1 file changed, 88 insertions(+), 28 deletions(-) diff --git a/scripts/pm/check-dispatch-gates.mjs b/scripts/pm/check-dispatch-gates.mjs index fb62ab8112f..653bf91c70a 100644 --- a/scripts/pm/check-dispatch-gates.mjs +++ b/scripts/pm/check-dispatch-gates.mjs @@ -64,18 +64,29 @@ * only decides which tier a run gets: * * a dev box, no flag `--self-test --fast` the fast tier, what a delivery runs - * `--slow` on argv `--self-test` both tiers, on demand - * `GITHUB_ACTIONS` is `true` `--self-test` both tiers, what CI runs - * - * The CI read is the one environment read this file makes, and its failure - * direction is the SAFE one: an unset variable where it should be set buys a - * shorter run that still prints which tier it was, never a quiet one; a set - * variable on a dev box buys the full battery, loud. `lint.yml` runs this gate - * with no argument, so the slow tier stays wired in CI without a workflow edit - * and without a second family for the derivation to name. The tier chosen and - * the reason are printed before the spawn, and the wall clock after it names - * the tier it measured. The fast tier fits a quiet box's foreground cap with - * room to spare; the detached form above stays the form for both tiers. + * `--slow` on argv `--self-test` both tiers, on demand (CI included) + * CI, a PR or a queue build `--self-test --fast` the fast tier (#22991) + * CI, any other event `--self-test` both tiers: `schedule`, `push` to main, a dispatch + * + * "CI" is `GITHUB_ACTIONS` set to `true`; the event is `GITHUB_EVENT_NAME`. + * The fast tier on CI is a CLOSED list of two events, `pull_request` and + * `merge_group` — the path every change waits on — under the #22991 ruling: + * that path runs the fast tier, the slow tier runs on the hourly `schedule` + * and on every `push` to main, so every case still runs at least daily + * (#22076's constraint) and a slow-tier red is a main red. Every other CI + * event keeps both tiers, `workflow_dispatch` included: the ruling moved the + * slow tier off those two events and no others, and a new trigger nobody + * thought about lands on the full battery rather than on the shorter one. + * + * Those two reads are the only environment reads this file makes, and their + * failure direction is the SAFE one: an unset variable where it should be set + * buys a shorter run on a dev box, or the full battery on CI, and either one + * prints which tier it was and why — never a quiet run. `lint.yml` runs this + * gate with no argument, so the tier follows the event without a workflow + * edit and without a second family for the derivation to name. The tier chosen + * and the reason are printed before the spawn, and the wall clock after it + * names the tier it measured. The fast tier fits a quiet box's foreground cap + * with room to spare; the detached form above stays the form for both tiers. * * ## The exit contract, and why the kill branch does not keep its old code * @@ -321,18 +332,45 @@ const SELF_TEST_CHILD_FLAG = '--self-test-child'; /** The flag that runs this file's own battery instead of the tool's. */ const SELF_TEST_FLAG = '--self-test'; -/** The flag that asks for both tiers off CI; without it a dev box runs the fast tier. */ +/** + * The flag that asks for both tiers wherever the event would pick the fast one — a dev box, + * or a CI run on one of FAST_TIER_EVENTS below; it is the manual override in both places. + */ const SLOW_TIER_FLAG = '--slow'; /** - * Which tier this run gets, and why — one read of argv and one of the + * The CI events that run the FAST tier (#22991): the PR and merge-queue path every change + * waits on. A closed list on purpose, so the failure direction stays the safe one — an event + * that is not on it (`schedule`, `push` to main, `workflow_dispatch`, a trigger added later) + * runs both tiers. The header's tier table argues the ruling this encodes. + */ +const FAST_TIER_EVENTS = Object.freeze(['pull_request', 'merge_group']); + +/** + * Which tier this run gets, and why — one read of argv and two of the * environment, decided here so the spawn below and its announcement cannot * disagree. Not exported: this file is a CLI, and the self-test drives the * decision through spawned children that echo the argv they were given. */ function tierOfThisRun({ argv = process.argv.slice(2), env = process.env } = {}) { if (argv.includes(SLOW_TIER_FLAG)) return { args: [], word: 'both tiers', why: `${SLOW_TIER_FLAG} on argv` }; - if (env.GITHUB_ACTIONS === 'true') return { args: [], word: 'both tiers', why: 'GITHUB_ACTIONS is true — the CI run owes the slow tier' }; + if (env.GITHUB_ACTIONS === 'true') { + const event = env.GITHUB_EVENT_NAME ?? ''; + if (FAST_TIER_EVENTS.includes(event)) { + return { + args: ['--fast'], + word: 'the fast tier', + why: + `GITHUB_EVENT_NAME is ${event} — the PR and merge-queue path runs the fast tier; ` + + `the slow tier runs on schedule and on push to main (#22991); ${SLOW_TIER_FLAG} runs both`, + }; + } + return { + args: [], + word: 'both tiers', + why: `GITHUB_EVENT_NAME is ${event === '' ? '(unset)' : event} — a CI run off the PR and merge-queue path owes the slow tier`, + }; + } return { args: ['--fast'], word: 'the fast tier', why: `the default off CI; ${SLOW_TIER_FLAG} runs both` }; } @@ -478,10 +516,13 @@ async function selfTest() { const redChild = stub('red.mjs', 'process.exit(1);\n'); const greenChild = stub('green.mjs', 'process.exit(0);\n'); + // Both CI variables are blanked unless a case sets them: this battery itself runs on CI, + // where the runner's own `GITHUB_EVENT_NAME` would otherwise leak into every case and decide + // the tier the cases below claim to be driving. const drive = (childPath, { extra = [], env = {} } = {}) => spawnSync(process.execPath, [SELF, SELF_TEST_CHILD_FLAG, childPath, ...extra], { encoding: 'utf8', - env: { ...process.env, GITHUB_ACTIONS: '', ...env }, + env: { ...process.env, GITHUB_ACTIONS: '', GITHUB_EVENT_NAME: '', ...env }, }); const killed = drive(killedChild); @@ -491,20 +532,39 @@ async function selfTest() { // The tier decision, driven against a child that echoes the argv it was given. const echoChild = stub('echo.mjs', "console.log(process.argv.slice(2).join(' '));\nprocess.exit(0);\n"); const offCi = drive(echoChild); - const onCi = drive(echoChild, { env: { GITHUB_ACTIONS: 'true' } }); const slowAsked = drive(echoChild, { extra: [SLOW_TIER_FLAG] }); + const onCi = (event, extra = []) => + drive(echoChild, { extra, env: { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: event } }); + const ranFast = (r) => + r.status === 0 && (r.stdout ?? '').trim() === '--self-test --fast' && r.stderr.includes('running the fast tier'); + const ranBoth = (r) => + r.status === 0 && (r.stdout ?? '').trim() === '--self-test' && r.stderr.includes('running both tiers'); + t('off CI with no flag the child runs the FAST tier — the argv it received says so', ranFast(offCi)); + // The #22991 event switch, one case per event lint.yml is triggered by, plus the two that + // decide its failure direction: an event nobody listed, and no event at all. + for (const event of FAST_TIER_EVENTS) { + const r = onCi(event); + t( + `⭐ on CI a ${event} run gets the FAST tier, and says which event decided it`, + ranFast(r) && r.stderr.includes(`GITHUB_EVENT_NAME is ${event}`), + ); + } + for (const event of ['schedule', 'push']) { + const r = onCi(event); + t( + `⭐ on CI a ${event} run gets BOTH tiers — the slow tier's daily home under the #22991 ruling`, + ranBoth(r) && r.stderr.includes(`GITHUB_EVENT_NAME is ${event}`), + ); + } t( - 'off CI with no flag the child runs the FAST tier — the argv it received says so', - offCi.status === 0 && (offCi.stdout ?? '').trim() === '--self-test --fast' && offCi.stderr.includes('running the fast tier'), - ); - t( - `⭐ under GITHUB_ACTIONS=true the child runs BOTH tiers — the slow tier stays wired in CI without a workflow edit`, - onCi.status === 0 && (onCi.stdout ?? '').trim() === '--self-test' && onCi.stderr.includes('running both tiers'), - ); - t( - `…and ${SLOW_TIER_FLAG} asks for both tiers off CI, on demand`, - slowAsked.status === 0 && (slowAsked.stdout ?? '').trim() === '--self-test' && slowAsked.stderr.includes('running both tiers'), + '…and so does workflow_dispatch: the ruling moved the slow tier off the PR and queue path, and nothing else', + ranBoth(onCi('workflow_dispatch')), ); + t('…and an event the list never named runs BOTH tiers — the safe direction, loud', ranBoth(onCi('a_trigger_added_later'))); + const noEvent = onCi(''); + t('…and a CI run with no event at all runs BOTH tiers and says the event was unset', ranBoth(noEvent) && noEvent.stderr.includes('(unset)')); + t(`${SLOW_TIER_FLAG} on a pull_request run still asks for both tiers — the manual override reaches CI too`, ranBoth(onCi('pull_request', [SLOW_TIER_FLAG]))); + t(`…and ${SLOW_TIER_FLAG} asks for both tiers off CI, on demand`, ranBoth(slowAsked)); t( 'CONTROL: the killed stub really died by SIGNAL, so the branch under test is the one that ran', @@ -560,7 +620,7 @@ async function selfTest() { console.log( failures === 0 - ? '✓ check:pm-dispatch-gates --self-test: the exit contract holds in all three directions, and the tier decision in both.' + ? '✓ check:pm-dispatch-gates --self-test: the exit contract holds in all three directions, and the tier decision off CI and per CI event.' : `✗ check:pm-dispatch-gates --self-test: ${failures} case(s) failed.`, ); process.exit(failures === 0 ? 0 : 1); From b93a70eb93fd506cce901db8ca2d0f7cfc5b3bda Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:17:06 +0000 Subject: [PATCH 2/5] ci(pm): the tier self-test names its fast events literally, so emptying the list reds it Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- scripts/pm/check-dispatch-gates.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/pm/check-dispatch-gates.mjs b/scripts/pm/check-dispatch-gates.mjs index 653bf91c70a..a333797d3b0 100644 --- a/scripts/pm/check-dispatch-gates.mjs +++ b/scripts/pm/check-dispatch-gates.mjs @@ -541,8 +541,11 @@ async function selfTest() { r.status === 0 && (r.stdout ?? '').trim() === '--self-test' && r.stderr.includes('running both tiers'); t('off CI with no flag the child runs the FAST tier — the argv it received says so', ranFast(offCi)); // The #22991 event switch, one case per event lint.yml is triggered by, plus the two that - // decide its failure direction: an event nobody listed, and no event at all. - for (const event of FAST_TIER_EVENTS) { + // decide its failure direction: an event nobody listed, and no event at all. ⛔ The fast + // events are spelled HERE, never read from FAST_TIER_EVENTS: a loop over the constant under + // test registers no case at all when the constant is emptied, and goes green over exactly + // the regression it exists to catch (measured: that ablation exited 0 before this line). + for (const event of ['pull_request', 'merge_group']) { const r = onCi(event); t( `⭐ on CI a ${event} run gets the FAST tier, and says which event decided it`, From d2303084955cafcc27a7845db2bdf6920173d5d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:21:07 +0000 Subject: [PATCH 3/5] ci(lint): Lint & Repo Gates becomes an aggregate over eight parallel lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single 193-step job is split into eight Lint · * lanes behind an aggregator that keeps the required context name byte-identical, with the TypeScript Type Check roster discipline: OS_AGGREGATOR_MEMBERS == needs, re-checked against toJSON(needs) at run time, if: always(). Every gate step moves verbatim (same run, same if, same comment block, same relative order). Each lane repeats the six setup steps, the gate-family selector where it carries a scoped step, and the unmeasured-tail reporter pair. Lane walls follow the Type Check lanes' checkout-budget-plus-body scheme. Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- .github/workflows/lint.yml | 1984 +++++++++++++++++++++++++----------- 1 file changed, 1389 insertions(+), 595 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d45e9074561..0f963b834e7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -173,46 +173,231 @@ jobs: # verbatim. # # It is not called `ESLint` any more because it never was only that: the - # `pnpm lint` step below is one of ~70 sequential gate steps, so a red here - # was routinely read as "a lint problem" when it was a repo gate — three - # mis-routed diagnoses on 2026-08-17 alone (#9258, PRs #9256/#9291). + # `pnpm lint` step (now in the `lint-eslint` lane) was one of ~70 sequential + # gate steps when this was written, so a red here was routinely read as "a + # lint problem" when it was a repo gate — three mis-routed diagnoses on + # 2026-08-17 alone (#9258, PRs #9256/#9291). + # + # ── AN AGGREGATE SINCE #22991 (ruling 6120820653, letter D) ────────────── + # This job used to run every gate itself: 193 serial steps on one runner, + # p50 17.8 / p90 35.9 / max 38.5 minutes over the 70 successful runs read + # for #22991 (2026-10-12T04:40Z → 08:45Z, `pull_request` and + # `merge_group`, job and step timestamps from the jobs API), and the job + # the merge queue waited on in 13 of 25 groups. It now runs NO gate. The + # gates run in the eight `Lint · *` lanes below, in parallel, and this job + # is their aggregate: the shape `TypeScript Type Check` and its + # `Type Check · *` lanes have in this file. The required context is this + # job's NAME, so the name is kept byte-identical (AGENTS.md's merge-queue + # paragraph: a required context is matched by check-run name). The lanes + # carry new names and are advisory on their own; what blocks is this + # aggregate's verdict over them. + # + # ⛔ NO GATE WAS DROPPED, WEAKENED OR GIVEN A NEW COMMAND. Every gate step of + # the single job runs in exactly one lane, on the same events, with the + # same `run:`, the same `if:`, in the same relative order and with its + # comment block. ONE exception, the ruling's: `pnpm check:pm-dispatch-gates` + # runs its fast tier on `pull_request` and `merge_group` and both tiers on + # `schedule` and `push` (argued in `scripts/pm/check-dispatch-gates.mjs`'s + # header). What each lane repeats is the six setup steps, the gate-family + # selector where the lane carries a scoped step, and the unmeasured-tail + # reporter pair. Every lane keeps the single job's full-history checkout: + # which of the ~190 gates read history was not re-derived for this split, + # and a gate that loses its history degrades to "not verified" rather than + # going red. + # + # The lanes are independent. The only inter-step reads in the single job + # were `steps.gate-families.outputs.*` (nine `if:` lines, one step `id:`), + # and no step built, installed or generated a file a later step read, so no + # lane can miss an artifact another lane would have written. Each lane + # still runs its gates sequentially and stops at its first red, so the GATE + # ORDER note above `jobs:` holds per lane. + # + # THE CUT, from the per-step timestamps of those 70 runs (a lane's wall + # rebuilt per run: that run's own setup steps plus the lane's steps as they + # ran there, skipped ones at zero): + # + # lane steps p50 max (minutes) + # lint-eslint 2 3.3 4.0 + # lint-repo-tooling 60 4.2 4.7 + # lint-docs-code 55 4.1 4.6 + # lint-ci-engine 58 3.9 4.4 + # lint-scoped-erasure 2 3.5 5.0 + # lint-scoped-slot-corpus 3 3.5 4.9 + # lint-scoped-tooling 3 1.1 4.4 + # lint-pm-dispatch-gates 1 1.1 5.1 fast tier, estimated + # + # The ruled budget is 5 minutes per lane at median, and a lane over budget + # SPLITS, it does not skip: that is why the nine path-scoped families sit + # in four lanes rather than one (the eight besides the PM self-test ran 9 + # minutes together). These are predictions; the real numbers are the PR's + # own CI run and every run after it. Not taken, also ruled: per-family + # path scoping for the always-on gates, and input caches. Both need a + # declaration of what a gate reads, and a wrong declaration skips a gate + # silently. name: Lint & Repo Gates + needs: + - lint-eslint + - lint-repo-tooling + - lint-docs-code + - lint-ci-engine + - lint-scoped-erasure + - lint-scoped-slot-corpus + - lint-scoped-tooling + - lint-pm-dispatch-gates + # ── ROSTER DECLARATION (#10490), the `typecheck` aggregate's discipline ── + # The lanes this required aggregate stands in for, named rather than + # counted. `node scripts/check-aggregator-roster.mjs` (statically, in + # `lint-ci-engine`) holds this list equal to `needs:` in both directions, + # and holds this job to declaring one at all, since it carries a required + # context; the verdict step below checks the same equality against + # `toJSON(needs)` at run time. A ninth lane added to this workflow and + # forgotten here would otherwise ride green as an ADVISORY check-run behind + # the required "Lint & Repo Gates" context. This aggregate has no + # non-member inputs: every job in `needs:` is a lane whose verdict it + # carries. + env: + OS_AGGREGATOR_MEMBERS: lint-eslint lint-repo-tooling lint-docs-code lint-ci-engine lint-scoped-erasure lint-scoped-slot-corpus lint-scoped-tooling lint-pm-dispatch-gates + # `always()` is load-bearing and must not become `success()` or the + # implicit bare condition: with those, a failed lane SKIPS this job, and a + # skipped required context is an absence, which reads as green in every + # checks list and never blocks the queue. The `typecheck` job carries the + # same contract and says so at length. + if: always() runs-on: ubuntu-latest - # ⭐ JOB WALL — measured, and the only wall this job has. No step in this - # workflow runs under `scripts/run-with-stall-guard.mjs` (if one ever does, - # `pnpm check:stall-guard-budget` holds its cap under this number), so a - # step that stops producing output — a build, an install, a network read - # that never answers — is ended by nothing else. Without the key GitHub's - # default is 360 minutes, and this is the longest REQUIRED check in every - # merge-queue group. The queue does not wait that long: ruleset 12119582 - # (`main`) treats a required check that has not reported within 60 minutes - # (`check_response_timeout_minutes`) as failed and removes the entry — with - # no red check, and no `Merge Queue Triage` comment either (that workflow - # fires on a `failure` conclusion only, and it watches `CI`, not this - # workflow) — while the hung job holds a runner for up to five more hours, - # still `in_progress`. With the wall, a hung run ends `cancelled`, carrying - # GitHub's failure annotation "The job has exceeded the maximum execution - # time of …", before the queue's own window closes. + # ⭐ JOB WALL — measured, and the rule for every job in this file. No step in + # this workflow runs under `scripts/run-with-stall-guard.mjs` (if one ever + # does, `pnpm check:stall-guard-budget` holds its cap under its job's + # number), so a step that stops producing output — a build, an install, a + # network read that never answers — is ended by nothing else. Without the + # key GitHub's default is 360 minutes, and this context is the longest + # REQUIRED check in every merge-queue group. The queue does not wait that + # long: ruleset 12119582 (`main`) treats a required check that has not + # reported within 60 minutes (`check_response_timeout_minutes`) as failed + # and removes the entry — with no red check, and no `Merge Queue Triage` + # comment either (that workflow fires on a `failure` conclusion only, and + # it watches `CI`, not this workflow) — while the hung job holds a runner + # for up to five more hours, still `in_progress`. With the wall, a hung run + # ends `cancelled`, carrying GitHub's failure annotation "The job has + # exceeded the maximum execution time of …", before the queue's own window + # closes. # - # Measured: p50 15.8 / p99 32.6 / max 32.7 minutes (n = 81); a docs-only - # change runs it in about 7–11, a code change in 25–33. The window, for - # every job in this file: the 100 most recent completed runs of this - # workflow, 2026-10-01T00:23Z → 03:24Z (96 runs read, 525 successful job - # rows), job `started_at` → `completed_at` from the jobs API. + # Measured, before #22991 split this job into lanes: p50 15.8 / p99 32.6 / + # max 32.7 minutes (n = 81); a docs-only change ran it in about 7–11, a + # code change in 25–33. The window, for every job in this file: the 100 + # most recent completed runs of this workflow, 2026-10-01T00:23Z → 03:24Z + # (96 runs read, 525 successful job rows), job `started_at` → + # `completed_at` from the jobs API. # # The rule, for every job in this file: about 2× the measured maximum, # rounded to a 5-minute grain, never below 10 minutes, never above the - # queue's 60-minute window. Here the window binds, not 2× (2 × 32.7 = - # 65.4), and the cap is placed BELOW it, at 55: this job's clock starts - # only once a runner picks it up, after the queue's has started, so the - # five minutes are room for that pickup, and 55 still leaves 22 minutes - # (1.7×) over the measured maximum. Above 60 the stretch between the queue - # removing an entry and this check turning `cancelled` only grows, so when - # the measured maximum nears the wall the remedy is a faster job (or the - # queue's own setting, which is the maintainer's) — never a higher number - # here. Re-measure over a fresh window before changing any value in this - # file. - timeout-minutes: 55 + # queue's 60-minute window. For the single job the window bound, not 2× + # (2 × 32.7 = 65.4), and the cap was placed BELOW it, at 55: a job's clock + # starts only once a runner picks it up, after the queue's has started, so + # the five minutes are room for that pickup. 55 stays the CAP every job in + # this file sits under, the lanes below included. Above 60 the stretch + # between the queue removing an entry and this check turning `cancelled` + # only grows, so when a measured maximum nears the wall the remedy is a + # faster job (or the queue's own setting, which is the maintainer's) — + # never a higher number here. Re-measure over a fresh window before + # changing any value in this file. + # + # THIS job's own wall, since #22991: 10, the rule's floor — it runs one + # sub-second verdict and no checkout, the `typecheck` aggregate's number. + # Its clock starts only after the last lane ends, so the queue's 60 minutes + # are spent by the slowest lane's wall plus this one; every lane sizes its + # own wall in its header. + timeout-minutes: 10 + permissions: + contents: read + + steps: + # Fail-closed, and deliberately NOT a hand-written list of the lane names: + # the verdict iterates `toJSON(needs)`, so every job wired into `needs:` + # above is enforced, and the other half of that drift — a lane added to + # the workflow but never wired into `needs:` — is caught by comparing + # `needs:` against the declared ROSTER. A copy of the `typecheck` + # aggregate's verdict, not a shared module: that one runs with no + # checkout, and so does this. Anything that is not exactly `success` + # fails, `skipped` and `cancelled` included: a lane that never ran has + # not proved anything. + - name: Verify every lint lane succeeded + env: + OS_NEEDS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + printf '%s' "$OS_NEEDS" > "$RUNNER_TEMP/needs.json" + cat > "$RUNNER_TEMP/verify-lanes.mjs" <<'NODE' + import { readFileSync } from 'node:fs'; + + // The roster this aggregator declares, read from the job-level + // `OS_AGGREGATOR_MEMBERS` above rather than re-typed here -- one + // declaration, two readers (this step, and the static + // scripts/check-aggregator-roster.mjs in the lint-ci-engine lane). + const declared = (process.env.OS_AGGREGATOR_MEMBERS ?? '').trim().split(/\s+/).filter(Boolean); + const declaredSet = new Set(declared); + + const needs = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/needs.json`, 'utf8')); + const ids = Object.keys(needs).sort(); + let bad = 0; + + // An empty declaration would make every comparison below trivially + // true. Refuse rather than report a green over an unread roster. + if (declared.length === 0) { + console.log( + '::error::this job declares no OS_AGGREGATOR_MEMBERS roster, so there is nothing to hold `needs:` to -- ' + + 'refusing to report a pass (#4690).', + ); + bad++; + } + + for (const id of ids) { + const result = needs[id]?.result ?? '(no result reported)'; + if (result === 'success') { + console.log(` ok ${id}`); + continue; + } + console.log(`::error::lint lane \`${id}\` concluded \`${result}\` -- expected \`success\`.`); + bad++; + } + + for (const id of declared.filter((d) => !ids.includes(d))) { + console.log( + `::error::declared lane \`${id}\` is missing from this job's \`needs:\`, so its verdict is NOT aggregated -- ` + + `it publishes an advisory check-run and rides green behind a required context.`, + ); + bad++; + } + + for (const id of ids.filter((i) => !declaredSet.has(i))) { + console.log( + `::error::job \`${id}\` is in this job's \`needs:\` but not in the declared roster -- add it to ` + + `OS_AGGREGATOR_MEMBERS, or drop it from \`needs:\`.`, + ); + bad++; + } + + if (bad > 0) { + console.log(`check-lint-lanes: FAILED (${bad} problem(s) above).`); + process.exit(1); + } + console.log(`check-lint-lanes: OK (${ids.length} declared lane(s) succeeded: ${ids.join(', ')}).`); + NODE + node "$RUNNER_TEMP/verify-lanes.mjs" + + lint-eslint: + # Lane 1 of 8 behind the required `Lint & Repo Gates` aggregate (#22991). + # The `lint` job above holds the contract, the cut and the roster: read it + # before moving a step between lanes. This lane: ESLint and the engine + # test-double contract gate, the two heaviest always-on gates, side by + # side. It also carries the unmeasured-tail reporter's full argument and + # the `actions: read` scope it needs, which every lane repeats. + name: Lint · ESLint + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20, on the checkout step) + BODY BUDGET, the + # scheme `typecheck-source-gates` argues for a full-history checkout. Body + # after the checkout, rebuilt per run from the 70 runs the `lint` job + # cites: max 3.3 minutes; 2 × 3.3 = 6.7, 10 on the floor. 20 + 10 = 30. + timeout-minutes: 30 permissions: contents: read # ⭐ Read-only, and it buys exactly one thing: the LAST step of this job @@ -222,31 +407,13 @@ 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 - # ⭐ 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 uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 with: # The slot-lookup ratchet compares the baseline against its state at # the merge base with main — the only way to see a file being ADDED @@ -278,127 +445,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # ── Gate-family selection (#16496) ─────────────────────────────────── - # After #16453 this job is the merge queue's critical path (median 1468 s - # over the ten merge-group runs measured for #16496; the PM dispatch-gates - # self-test alone 597 s), and none of its expensive steps read a - # merge group's file surface: a docs-only group paid the full battery. - # So on `merge_group` and `pull_request` the NINE scoped families below - # -- each step carrying `if: steps.gate-families.outputs. != 'skip'` - # -- run only when the changed paths touch the files that family reads. - # (A tenth, `migration_registry` (#19753), retired with its gate in - # #22554: the registry it compared is no longer committed.) - # `push` on main and the scheduled full run keep the whole battery: the - # script runs everything for any event it does not scope. - # - # #19498 added four of the nine (`entry_guard`, - # `declared_population_live`, `bare_root_worklist`, - # `self_test_workflow_commands`) and narrowed `pm_dispatch_gates`, on - # ruling #208 on #19491 (R4) and the maintainer's sentence quoted in the - # selector's header: the tooling's self-tests were 18.6 minutes of the - # 27.4-minute job a three-file `packages/spec` PR paid. ⛔ Product - # ratchets and censuses stay unconditional -- the tenancy and - # tenant-audit censuses, the engine gates and everything else here judge - # product code and are not in that ruling. - # - # ⭐ The invariant is FAIL-OPEN, and it holds at both layers. The script - # runs every family when the base cannot be resolved, the diff fails or - # is empty, a path is one it does not classify (a new top-level - # directory, an unlisted root file), or any change is a deletion, rename - # or type change; a family is skipped ONLY when every changed path is - # positively classified into a class that family's DECLARED read-set - # excludes. For the other four -- both ratchets, the corpus walk and the - # verify-lock self-test -- that read-set is what the family provably - # reads. For the five tooling self-tests it is the tool's OWN INPUTS, - # which is a weaker claim; it is argued where it is declared, in the - # selector's header. - # The `!= 'skip'` spelling means an ABSENT output -- the selector never - # ran, or wrote nothing -- also runs the step. Both halves are pinned by - # `scripts/ci/select-gate-families.selftest.sh` (`check:select-gate- - # families`, further down this job), which also reads THIS file and - # refuses any other `if:` spelling and any family the script does not - # decide. The per-family read-sets, and how each was measured, are in - # the script's header. - # - # The selection itself lives in scripts/ci/select-gate-families.sh: - # environment in, `$RUNNER_TEMP/gate-families.txt` + `$GITHUB_OUTPUT` - # out, and a ran/skipped table in the job summary (the seat reads it). - # This step only exports the event's fields, so the same script -- every - # branch of it -- runs locally and under its self-test. - - name: Select the gate families this run pays for - id: gate-families - env: - OS_GATE_EVENT_NAME: ${{ github.event_name }} - OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} - run: | - bash scripts/ci/select-gate-families.sh - - # #7484 docs anchors: `lychee.toml` sets `include_fragments = "none"`, so - # the `Check Documentation Links` job resolves a link's FILE and never its - # `#anchor` — measured with the pinned lychee 0.24.2 under the CI argv, a - # link to a heading that does not exist is reported `[200] OK`. So a - # cross-file anchor was a two-sided invariant with no owner: rename the - # heading, leave the inbound link, ship green. This resolves every internal - # `#fragment` against the destination page's real heading ids, computed - # with `github-slugger` — the same package fumadocs-core renders the site - # with, so the gate and the renderer cannot disagree about a slug. - # - # It lives in this job rather than in check-links.yml for two reasons: it - # needs a workspace install (that job is deliberately a checkout plus two - # dependency-free `node` checks), and it is REQUIRED where that lane is - # advisory. The four anchors that were already dead were fixed in the same - # PR, so this ships with no baseline and no allowlist. - # - # ── FOLDED IN, deliberately: the `file:line` anchor half ────────────── - # - # `content/docs/permissions/system-context.mdx` calls itself "the - # authority" for every behaviour keyed off `ExecutionContext.isSystem` - # and says it is "built by census over the whole repo, not by recall". - # Nothing held it to either claim, and it rotted at a measured rate: 101 - # of its 111 anchors pointed at a line that no longer held what the row - # named, 19 days after the census was written, with CI green throughout. - # - # ⭐ The load-bearing direction is CENSUS -> PAGE, not page -> code. At - # the commit that motivated the card, re-resolving EVERY anchor on the - # page would have passed while it was missing 32 sites and its headline - # was 29 too low: a gate that only checks what a page already says can - # never find what the page failed to say. Measured again here on the - # shipped gate -- with an unanchored read site injected, the resolution - # checks report 0 findings and the population check names the site. - # - # It is a second command in THIS step rather than a step of its own: this - # job has no paths filter, so the census runs on code-only PRs (the ones - # that ADD a read site), and folding costs no new check context, no new - # `check:*` manifest key, and no new required-status entry for one page. - # The `--self-test` invocation is what `check:self-test-wired` requires, - # and it is the only instrument on this gate's matching rules -- a clean - # tree cannot tell a working rule from a weakened one. - # - # Invoked as `node` rather than through a `pnpm check:*` alias: see the - # GATE INVOCATION IDIOM note at the top of this file. - - name: Docs anchors resolve to real headings - run: | - pnpm check:doc-anchors - node scripts/check-system-context-census.mjs --self-test - node scripts/check-system-context-census.mjs - - # #3723 ADR anchors: code an accepted ADR governs must keep naming it. - # That incident reversed three accepted ADRs with a patch-level changeset, - # and the mechanism was simply that the edited file never mentioned them — - # so the author could not have known. Presence check only; the invariant - # travels in the failure message (scripts/adr-anchors/, one file per anchor since #6957). - # - # Since #5992 the same script also audits the premise the anchors rest on: - # an ADR number names exactly ONE decision. Three numbers were each claimed - # by two unrelated records (0010/0019/0057) — grandfathered on an explicit - # shrink-only allowlist in the script, so a FOURTH collision goes red here. - # The `--self-test` in the pnpm script provokes that red path on synthetic - # directories every run, because a collision is rare enough that nobody - # would otherwise see the gate fail before it mattered. - - name: ADR anchors + number uniqueness (governed code names its decision) - run: pnpm check:adr-anchors - # No "migration registry matches its entry files" step since #22554: # `packages/spec/src/migrations/registry.ts` is git-ignored and generated # whole from `registry.ts.template` + `entries/` by `gen:migration-registry`, @@ -412,41 +458,201 @@ jobs: - name: ESLint run: pnpm lint - # Slot-lookup sweep ratchet (#4251). `pnpm lint` above bans erasing a - # service-lookup result to `any` across packages/, but the files still - # holding pre-existing sites are grandfathered by path — and an ignored - # file is ignored COMPLETELY, so new erasures added to one ride the old - # entry in silence. This re-measures those files with the grandfathering - # lifted and holds them to a per-file count, so the list can only shrink. - # - # Scoped (#16496): reads `packages/**/*.{ts,tsx,mts,cts}`, its baseline, - # `eslint.config.mjs` and the top-level `scripts/*.mjs` it imports; a - # group touching none of those skips it (selection step above). - - name: Slot-lookup ratchet - if: steps.gate-families.outputs.slot_lookup != 'skip' - run: pnpm check:slot-lookup + # Engine test-double contract gate (#4550, from #4434). A test double + # LOOSER than the implementation it replaces turns a green suite into no + # suite at all, silently, on exactly the paths a double was introduced + # for. #4434 is the worked example: DELETE /sharing/rules/:idOrName + # answered 500 for every rule and both address forms from the day it was + # written, while `deleteRule drops rule + all its grants` asserted success + # against a fake engine that accepted the one call shape ObjectQL.delete + # refuses. This holds every fake ObjectQL engine's `delete` to the real + # dispatch predicate — imported from @objectstack/objectql, not + # hand-mirrored, so it cannot drift — with the pre-existing fakes in a + # shrink-only, measured baseline. Static AST only, so it needs no build + # and belongs in this job. Runs its own --self-test first: the detector + # can be broken while every fake is fine, and a scan that quietly stops + # matching would report OK while reading nothing (#4868's family). + - name: Engine test-double contract gate + run: pnpm check:engine-double-contract - # Engine query-options erasure ratchet (#4918). The slot-lookup rule above - # protects the service LOOKUP; this one protects what you pass to the - # service you looked up. `IDataEngine.find/findOne/count/aggregate` declare - # their options as `EngineQueryOptions` & co., and for an internal caller - # `tsc` is the ONLY channel enforcing them — the protocol's ingress - # normalizer never sees a direct engine call, and the options schemas are - # not `.strict()`, so an unknown key is silently DROPPED. #4674 is the - # bill: two queries sorted by `direction` instead of `order`, both with a - # `limit`, so both returned the OLDEST rows — audit history that never - # showed recent changes, and a search that truncated away fresh records. - # #4720 restored those two sites and #4721 closed the external callers; - # this stops the shape regrowing internally. Same ratchet mechanism as - # slot-lookup for the non-test residual, plus one aggregate decrease-only - # number for test code (a test whose subject IS off-contract engine input - # must be able to build it). Runs its own --self-test first. + # ── NOT GATES. The two steps below judge nothing about the tree. ────── # - # Scoped (#16496): the same population and inputs as the slot-lookup - # ratchet above, with `scripts/query-options-erasure-baseline.json`. - - name: Engine query-options erasure ratchet - if: steps.gate-families.outputs.query_options_erasure != 'skip' - run: pnpm check:query-options-erasure + # ⭐ One red in this job is a LOWER BOUND on the number of problems in the + # tree, not a count — every gate behind the failing one never executed, + # and a gate that never executed produced no output, which is the same + # shape a passing gate has. The GATE ORDER note above `jobs:` already + # names this quantity ("the unmeasured tail") and is tuned to shrink it; + # until now nothing printed it. These steps print it. + # + # ⛔ The fix is NOT to make the skipped gates run: that would undo the + # adjudicated fail-fast order. ⛔ Nor is it `continue-on-error`, which + # trades a red for silence. The job's conclusion is untouched. + # + # Measured before this was written, on run 34468505490 of this very + # workflow (job 102842713265, failed at step #55): the jobs API and the web + # UI DO mark the un-run steps `skipped` — 112 of them there — but they + # never count them, and they use that same word for a step its own `if:` + # turned off, so the one surface that says it cannot tell the two apart. + # The check-run `output` was null on title, summary and text alike, and the + # job log — what an API or agent reader gets — said nothing at all. So what + # is added here is a machine-readable COUNT, on all three surfaces. + + # Offline, no token, no network: drives the reporter's judge() over + # recorded jobs-API shapes, and checks that the step below still hands + # itself its own name. Wired as a step because an unrun self-test is a + # phantom check. Sub-second. + # + # ⭐ `if: failure()` for the same reason the step below carries it, and it + # is the whole negative control: a green run of this job must be BYTE + # IDENTICAL to what it was before any of this existed. A self-test that + # ran unconditionally would print one line on every green run — one line + # is still new noise, and this change is not allowed to cost any. It is + # exercised on exactly the runs its subject is used on, which in this repo + # is not a rare event. ⛔ Do not "fix" this by dropping the condition. + # It fails LOUDLY: it is a separate step, so a broken reporter shows up as + # its own red step rather than as a missing paragraph. + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-repo-tooling: + # Lane 2 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). The always-on gates that ran from the top of the single job down + # to the two tenancy censuses: the docs and ADR anchors (still first, per + # the GATE ORDER note above `jobs:`), the `scripts/**` guards and + # shared-module self-tests, the PM tooling self-tests, the Claude hook + # guards, the docs authoring gates and both censuses. + name: Lint · repo & tooling gates + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 4.1 minutes; 2 × 4.1 = 8.1, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # #7484 docs anchors: `lychee.toml` sets `include_fragments = "none"`, so + # the `Check Documentation Links` job resolves a link's FILE and never its + # `#anchor` — measured with the pinned lychee 0.24.2 under the CI argv, a + # link to a heading that does not exist is reported `[200] OK`. So a + # cross-file anchor was a two-sided invariant with no owner: rename the + # heading, leave the inbound link, ship green. This resolves every internal + # `#fragment` against the destination page's real heading ids, computed + # with `github-slugger` — the same package fumadocs-core renders the site + # with, so the gate and the renderer cannot disagree about a slug. + # + # It lives in this job rather than in check-links.yml for two reasons: it + # needs a workspace install (that job is deliberately a checkout plus two + # dependency-free `node` checks), and it is REQUIRED where that lane is + # advisory. The four anchors that were already dead were fixed in the same + # PR, so this ships with no baseline and no allowlist. + # + # ── FOLDED IN, deliberately: the `file:line` anchor half ────────────── + # + # `content/docs/permissions/system-context.mdx` calls itself "the + # authority" for every behaviour keyed off `ExecutionContext.isSystem` + # and says it is "built by census over the whole repo, not by recall". + # Nothing held it to either claim, and it rotted at a measured rate: 101 + # of its 111 anchors pointed at a line that no longer held what the row + # named, 19 days after the census was written, with CI green throughout. + # + # ⭐ The load-bearing direction is CENSUS -> PAGE, not page -> code. At + # the commit that motivated the card, re-resolving EVERY anchor on the + # page would have passed while it was missing 32 sites and its headline + # was 29 too low: a gate that only checks what a page already says can + # never find what the page failed to say. Measured again here on the + # shipped gate -- with an unanchored read site injected, the resolution + # checks report 0 findings and the population check names the site. + # + # It is a second command in THIS step rather than a step of its own: this + # job has no paths filter, so the census runs on code-only PRs (the ones + # that ADD a read site), and folding costs no new check context, no new + # `check:*` manifest key, and no new required-status entry for one page. + # The `--self-test` invocation is what `check:self-test-wired` requires, + # and it is the only instrument on this gate's matching rules -- a clean + # tree cannot tell a working rule from a weakened one. + # + # Invoked as `node` rather than through a `pnpm check:*` alias: see the + # GATE INVOCATION IDIOM note at the top of this file. + - name: Docs anchors resolve to real headings + run: | + pnpm check:doc-anchors + node scripts/check-system-context-census.mjs --self-test + node scripts/check-system-context-census.mjs + + # #3723 ADR anchors: code an accepted ADR governs must keep naming it. + # That incident reversed three accepted ADRs with a patch-level changeset, + # and the mechanism was simply that the edited file never mentioned them — + # so the author could not have known. Presence check only; the invariant + # travels in the failure message (scripts/adr-anchors/, one file per anchor since #6957). + # + # Since #5992 the same script also audits the premise the anchors rest on: + # an ADR number names exactly ONE decision. Three numbers were each claimed + # by two unrelated records (0010/0019/0057) — grandfathered on an explicit + # shrink-only allowlist in the script, so a FOURTH collision goes red here. + # The `--self-test` in the pnpm script provokes that red path on synthetic + # directories every run, because a collision is rare enough that nobody + # would otherwise see the gate fail before it mattered. + - name: ADR anchors + number uniqueness (governed code names its decision) + run: pnpm check:adr-anchors # @objectstack/verify stand-in erasure guard (#6399). The third and # narrowest member of the two above. `checkReadCoercion` / @@ -623,36 +829,6 @@ jobs: fi echo "scripts/ shared-module self-tests — all four ran and passed" - # Every `scripts/**` entry guard goes through ONE predicate (#10086). - # The hand-typed forms of "did node run me, or did someone import me?" - # had drifted into ELEVEN spellings across 33 files, and NINE were wrong - # in the same invisible direction: node resolves symlinks for the module - # graph but leaves `process.argv[1]` as the caller typed it, so a script - # reached through a symlink compared two different paths, answered false, - # and did NOTHING — exit 0, no output. The CI wrappers here hold the - # child's exit STATUS only, so an inert child read as a green gate; the - # governed-surface register (`scripts/pm/check-governed-merges.mjs`) was - # among the affected, where the inert run and its "NOT governed, ordinary - # queue landing applies" verdict are the SAME exit code. - # The sweep alone would not have held — nothing stopped a twelfth - # spelling. This gate is the part that closes the class: only - # `scripts/invoked-as.mjs` may read `process.argv[1]`, and that module's - # own self-test drives a real probe through a real symlink. Rationale and - # the rejected behavioural-sweep alternative: the gate script's header. - # Scans ~115 files, no spawns; ~0.2s of work, measured at 0.62 min as a - # step on run 35506407130. - # - # Scoped (#19498): its population is `scripts/**` — its own - # ROOT_DIR_WATCH_HINTS declaration, held against the root it really walks - # by its own self-test — so a group that changes no file under scripts/ - # (and no root configuration) skips it. The selection step at the top of - # this job decides that, `push` on main and the hourly run keep it - # unconditional, and the ruling that authorizes moving a tooling - # self-test off the PR path is quoted in the selector's header. - - name: scripts/ entry guards go through one predicate - if: steps.gate-families.outputs.entry_guard != 'skip' - run: pnpm check:entry-guard - # Every `scripts/**` TypeScript parse goes through ONE module (#10133 / # #10573), and this is the half that keeps it that way (#10574). # NONE of the three parser entry points throws on a source it cannot @@ -809,51 +985,6 @@ jobs: - name: shell scripts hold the bash 3.2 floor run: pnpm check:bash32-floor - # The comment-mask CORPUS sweep (#10640) — the other instrument for the - # module the two gates above delegate "is this span a comment, or code?" - # to. `js-comment-mask.mjs --self-test` pins the SHAPES someone wrote - # down; this parses every .{ts,tsx,mts,cts,js,mjs,cjs,jsx} file in the - # tree with @typescript-eslint/parser and diffs its comment ranges - # against the mask's, byte for byte. Neither subsumes the other, and that - # is measured in both directions on this tree rather than assumed: - # deleting the `{` counting inside `${...}` fails a pinned case and the - # sweep reads 0 disagreements over 4,741 files (the tree does not write - # that shape), while dropping `return` from REGEX_AFTER_KEYWORD passes all - # 23 pinned cases and the sweep names check-test-source-alias.mjs, where - # `return /(^|[^a-z])dist\//` is written today. - # The defect this would have caught shipped: 16 files disagreeing, 15 of - # them reading commented-out text as live code, 10,252 comment bytes in - # one file — and the instrument that found it lived in an agent's - # scratchpad, which is why re-deriving it was impossible until this file. - # WHY A HARD GATE ON EVERY PR, and not an on-demand flag: the sweep's - # verdict depends on the CORPUS as much as on the masker, so a PR that - # writes a shape the mask reads wrong turns ~20 source-scanning gates - # into liars on that file, on a PR that never touched the masker. That - # arrival is not detectable by any convention a human or an agent has to - # remember at the right moment — the failure mode this repo has already - # paid for at #4690 and again here. - # WHY A STEP AND NOT A JOB: a new job's `name:` is a required-context - # contract (scripts/check-required-contexts.mjs) that only a - # maintainer-present Settings change can enrol, and an unenrolled job is - # an advisory gate — #5617 verbatim. This job is already required. - # Invoked as `node` rather than `pnpm check:*`, same as the other - # `node scripts/…` steps in this lane: see the GATE INVOCATION IDIOM note - # at the top of this file. - # Runs its own comparator self-test first — 12 cases proving the - # comparison can still REPORT, because "0 files disagree" and "the - # comparison is broken" otherwise print the same line. - # Cost, measured on a contended 4-vCPU container: 4,741 files, 72 MB of - # source, 45–48 s, of which the parser is 45 s. The step prints CI's own - # number on every run. - # - # Scoped (#16496): the walk reads every `.ts .tsx .mts .cts .js .mjs - # .cjs .jsx` file in the tree, so a group that changes no file of those - # extensions (and no root configuration) skips it -- selection step at - # the top of this job. - - name: Comment mask agrees with a real parser over the whole corpus - if: steps.gate-families.outputs.comment_mask_corpus != 'skip' - run: node scripts/check-comment-mask-corpus.mjs - # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` # decides which collections a stack may declare; eight other enumerations # of that same set are hand-maintained (the map-format list, the @@ -970,84 +1101,6 @@ jobs: - name: PM label description cap run: pnpm check:pm-label-desc-cap - # PM dispatch-gates self-test (#8162). `scripts/pm/dispatch-gates.mjs` - # derives the "local gates for this card" line of every dispatch prompt, - # and carried a 61-case --self-test that NO job ran: it executed only when - # a human or an agent typed it. A break in the extraction functions landed - # green and surfaced later as a prompt naming the wrong gate families — - # output that reads as correct, produced by the one tool whose purpose is - # to stop gate lists being memory-shaped. Same family as the - # `Changeset-family gate self-tests` step above. Until #16496 this step - # was deliberately UNCONDITIONAL -- no `if:`, no label read, no paths - # filter -- on the argument that an exemption is what a self-test must - # not have, or the gap moves. That argument was about an exemption a - # PATH FILTER or a LABEL would grant: a paths: trigger or a label read - # skips the step on a card that never touched the paths someone once - # listed, with no reader of what the test actually reads. What runs - # here now is different in kind and was the maintainer's call (#16496, - # 「同意你的建议,你负责执行派发所有可行的优化」): the selection step at - # the top of this job classifies every changed path against a declared - # read-set for this step, and skips it when no changed path is in that - # set. Every doubt runs it, the self-test of the selector pins that, and - # `push` on main keeps it unconditional. - # - # ⚠️ Since #19498 that read-set is NARROWER than what the battery reads, - # and the gap is stated rather than papered over. The read-set is the - # tool's own inputs: every workflow and composite action, every gate - # source under `scripts/**` and a workspace package's own `scripts/`, - # every `package.json`, `.claude/**`, `skills/**`, `AGENTS.md`, - # `CLAUDE.md` and root configuration. The battery ALSO reads the CONTENT - # of every JS/TS and `.sh` file in the tree (the compound-anchor census - # of `function ...SelfTest...(` declarations and the exposed-scratch-dir - # sweep of every mkdtempSync/mkdirSync caller, with the nested - # `.gitignore` files it consults) and the tracked NAME set it sweeps — - # which is why, until #19498, any added file anywhere ran it. On PR - # #19314 this step alone was 11.8 minutes of a 27.4-minute job for a - # three-file `packages/spec` diff, and ruling #208 on #19491 (R4) took - # those whole-tree reads off the PR path on the maintainer's sentence, - # quoted in the selector's header. ⛔ A defect in that wider set can now - # first appear on `main`; the push-on-main and hourly full runs are what - # bound it, and widening the skip further is again a maintainer call. - # - # The gate runs the SELF-TEST only. The live derivation - # (`node scripts/pm/dispatch-gates.mjs `) answers a question about a - # card's file surface, is print-only, and exits 0 on any completed run — - # there is no verdict in it for CI to hold, only a slow read of the whole - # workflow tree. Why the gate is its own file rather than pointing - # `check:pm-dispatch-gates` straight at the tool is measured and argued in - # `scripts/pm/check-dispatch-gates.mjs`'s header (the tool's self-test - # fixtures are path strings, and they become watch hints that fabricate - # MATCHED leads across most of the tree). - # - # ⛔ This paragraph deliberately carries NO cost figure (#12831). The line - # that used to close it — "Reads two files and walks packages/; ~0.3s" — - # described the 61-case self-test named at the top, and the part that grew - # since is exactly the part it denied: the live-tree cases run the same - # discovery the tool does (every workflow file, and then the source of - # every gate that discovery finds), build temporary git repositories and - # drive `changedPathsFromGit` against them, and sweep the tracked corpus. - # That is not "two files", and it is not a walk of packages/. The estimate - # went with the description rather than being refreshed: a replacement has - # to be read off a real CI run of THIS step, because a reading taken in an - # agent container is a reading of a contended box and not of a runner - # (#12528). A cost nobody re-measures ends up endorsing a decision it has - # no standing to endorse, which is what this one had become. No live - # counts stand here either, for the same reason — they rot the same way, - # and it is the SHAPE of the work, not its size, that the argument needs. - # - # ⛔ Cost was never what decided this step's conditionality, and the - # measured medians that motivated #16496 (597 s here, over ten - # merge-group runs) live in that card and its PR, not in this note. - # Losing the estimate is NOT an argument for a label read or a paths - # filter, and the read-set-scoped `if:` below is not one of those: it - # was a maintainer call (the first paragraph names it), and widening the - # skip -- any change to the classes the selector treats as inert for - # this family -- is again a maintainer call, taken in - # scripts/ci/select-gate-families.sh under its self-test. - - name: PM dispatch-gates self-test - if: steps.gate-families.outputs.pm_dispatch_gates != 'skip' - run: pnpm check:pm-dispatch-gates - # Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER # (#12762). The step above proves the extractor still works; this one # proves it still has something to extract. `extractWatchHints` reads @@ -1067,34 +1120,6 @@ jobs: - name: ROOT_DIR_WATCH_HINTS declarations are literals run: pnpm check:watch-hint-literal - # The other half of the same failure (#13519). The step above holds that a - # declaration is still READABLE BY A TEXT SCANNER; this one holds that - # what it reads still names something. A family whose declared literals - # all reach zero tracked files prints as an ORDINARY SILENCE — byte for - # byte the output of a gate that declared nothing — so the derivation and - # the gate disagree about whether a population was declared and neither - # says so. Measured on the tree this landed against: two families, one of - # them a gate that sweeps every workflow file and appeared on no card that - # edited one. The rule is per-FAMILY, not per-literal: a gate may spell a - # slug or a sentinel path beside a live declaration, and only a whole - # declaration that reaches nothing is refused. No allowlist and no gate - # names — the stronger rule ("a gate that enumerates a directory must - # declare one") was implemented, measured at 86 findings over 114 - # enumerating gate files, and refused as an allowlist with a verdict - # attached. Reads the derivation once over the tracked corpus; ~5s of - # work, measured at 0.40 min as a step on run 35506407130. - # - # Scoped (#19498): it imports `discoverFamilies` and `trackedFiles` from - # the dispatch derivation, so its inputs are the workflow tree, every - # gate source that discovery resolves, and the tracked NAME set — and - # only a name that DISAPPEARS can turn a live declaration dead, which is - # a structural change the selector already runs everything for. A group - # confined to product source, tests, docs or changesets skips it here and - # pays for it on `push` to main and on the hourly run. - - name: A declared gate population reaches the tree - if: steps.gate-families.outputs.declared_population_live != 'skip' - run: pnpm check:declared-population-live - # ADR-0087 D4's per-release correctness gate (#17080). The REAL run needs # two published tarballs and npm, so it lives in the release lane and # cannot run on a PR; what runs here is its `--self-test`, which is @@ -1107,37 +1132,6 @@ jobs: - name: Per-release spec-changes gate self-test run: pnpm check:release-spec-changes - # PM bare-root worklist self-test (#10840). The step above proves the - # dispatch derivation still WORKS; this one proves the recorded triage of - # the gates that derivation structurally cannot see is still true of the - # tree. A gate whose population is spelled as a bare single-segment word - # builds no watch hint at all — not a dead hint, not a silent verdict, - # nothing — so it is unnameable by any dispatch brief and leaves no residue - # saying so. The worklist enumerates that species and carries one recorded - # verdict per row; this step fails when a verdict outlives its row (the - # shrink) or when a new gate joins the species unjudged. - # - # ⛔ It runs the SELF-TEST only, never the sweep as a verdict: recognising - # this species needs a heuristic over constant NAMES, and #10705 refused to - # put one on the path that derives every PR's gate list. Nothing in - # `dispatch-gates.mjs` reads the worklist, and no verdict in it reaches a - # dispatch prompt. - # - # Reads the workflow tree and every gate source once; ~0.5s of work, - # measured at 0.32 min as a step on run 35506407130. - # - # ⚠️ This step WAS unconditional, on the reasoning that a self-test which - # can be skipped is the gap moving rather than closing. #19498 scopes it - # anyway, and the trade is explicit rather than reasoned away: it imports - # the same derivation as the two steps above, so its inputs are the - # workflow tree and the gate sources, and on a group touching neither the - # gap does move — to `push` on main and the hourly full run, which keep - # the whole battery. Gate weakening is a maintainer floor; the sentence - # that authorizes this one is quoted in the selector's header. - - name: PM bare-root worklist self-test - if: steps.gate-families.outputs.bare_root_worklist != 'skip' - run: node scripts/pm/bare-root-worklist.mjs --self-test - # Part-of/closing-keyword guard self-test (#8476). The guard itself is a # PR-scoped blocking check in its own workflow — it needs a pull request # body to judge, which this job does not have — so what runs HERE is its @@ -1894,103 +1888,6 @@ jobs: node scripts/check-self-test-wired.mjs --self-test node scripts/check-self-test-wired.mjs - # Self-test output vs. the runner's command parser (#11886). Sibling of - # the step above and sharing its population: that one asks whether CI RUNS - # each self-test, this one asks what each self-test PRINTS while it does. - # - # The defect it closes was live on every PR in this repo. A line of - # `scripts/pm/ci-failure.mjs`'s self-test prose named a workflow-command - # token literally, inside backticks, as documentation of what that tool - # anchors on. The runner does not know a token is being quoted: it parsed - # it and minted `annotation_level: failure` on a check run whose - # conclusion was SUCCESS. On `d63b01436` that artifact was the ONLY - # annotation the `Lint & Repo Gates` run carried. A failure-level - # annotation on a green required check is a claim that something failed, - # and a repo that mints one on every PR is teaching its readers to skim - # exactly the surface other gates report findings into. - # - # ⚠️ INVISIBLE TO EVERY LOCAL RUN, which is the whole reason it is a gate: - # the text is ordinary prose on disk and in a terminal, and only a runner - # transforms it. It was found by reading one job's annotations through the - # API, not by anything in this file. - # - # Two facts were measured on real runs and the gate's shape follows them: - # the legacy `##[...]` form is parsed ANYWHERE in a printed line (the - # measured token sat at column 18 and was still consumed), while the - # `::...::` form is parsed only at LINE START (two mid-sentence ones print - # on every PR from `check-prerelease-pin-watch` and mint nothing). So the - # first detector is unanchored and the second is anchored — flagging inert - # prose would be a false positive the next author would be right to delete. - # - # It RUNS the self-tests rather than reasoning about their source, because - # "is this token printed?" is only answerable by printing it. A static - # prefilter picks which ones to run — over-selecting freely, since the - # verdict never comes from it — which is what keeps this step at ~14 s - # instead of the ~4 min the whole population costs. - # - # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias: - # see the GATE INVOCATION IDIOM note at the top of this file. Reads - # `scripts/` and `.github/workflows/` off disk and spawns the selected - # self-tests; no network. Measured at 0.48 min as a step on run - # 35506407130. - # - # Scoped (#19498): its declared population is - # `scripts/**/*.mjs`, `scripts/**/*.mts` and `scripts/**/*.sh`, and the - # rest of its read-set is the workflow tree and `.github/actions` it - # discovers the runnable self-tests from — so a group touching none of - # those skips it here and runs it on `push` to main and on the hourly - # full run. - - name: Self-test workflow-command gate - if: steps.gate-families.outputs.self_test_workflow_commands != 'skip' - run: | - node scripts/check-self-test-workflow-commands.mjs --self-test - node scripts/check-self-test-workflow-commands.mjs - - # Verify-lock entry-point self-test (#9661). `scripts/pm/os-verify-lock.sh` - # is the ONE way an agent takes the container's shared heavy-verify lock, - # and it is the enforcement of a rule that used to live only in prose: the - # acquisition budget os-dev.md declares is now unrepresentable above its - # cap, grants are ordered by a ticket file, and every run reports how long - # it held. Measured before it existed, in one container: five live waiters, - # three of them 6x over the declared cap, and the compliant ones were the - # ones not verifying — a convention whose violators win is not a convention. - # - # Nothing in CI *uses* the lock (GitHub runners are one job per machine), - # so this step exists to keep the mechanism honest rather than to serialise - # anything: 48 cases over the budget clamp, ticket liveness (dead pid, - # REUSED pid, over-age), fd hygiene, exit-code passthrough, and real - # multi-process contention — queue timeout, legacy free-hand coexistence in - # both directions, and three staggered waiters acquiring in arrival order. - # - # Since #10289 it also guards the BASH 3.2 FLOOR and the bounded-refusal - # rule, which is why the count moved: `/usr/bin/env bash` is 3.2.57 on - # macOS, and a bash 4+/5+ construct there does not fail loudly — it makes - # the acquisition deadline unreachable and turns the wait into an unbounded - # spin that prints no VERDICT at all. So the suite now scans the - # acquisition path for 4+/5+ constructs, runs a real acquisition with - # EPOCHSECONDS/EPOCHREALTIME unset and mapfile disabled, and asserts that - # an unusable host (no `flock`) and a stopped clock each REFUSE with a - # verdict and a non-zero exit rather than waiting forever. - # - # It runs entirely on a private lock under a temp dir; it never touches - # /tmp/os-heavy-verify.lock, so a runner and an agent container behave the - # same. No network; median 100 s over the ten merge-group runs measured - # for #16496. - # - # Scoped (#16496): the suite reads the script itself (`$SELF`) and a - # private temp dir, and its case (h) runs the real entry point from the - # repo root, which routes through filter_preflight -> - # scripts/pnpm-filter-targets.mjs --preflight and so reads the top-level - # scripts/*.mjs helpers that imports, pnpm-workspace.yaml and every - # workspace package.json. A group touching none of those skips it. That - # is not the exemption the self-tests above refuse (a label or a paths - # filter with no reader behind it): the selection step at the top of - # this job runs this step on every doubt, and `push` on main keeps it - # unconditional. - - name: Verify-lock entry-point self-test - if: steps.gate-families.outputs.verify_lock != 'skip' - run: bash scripts/pm/os-verify-lock.sh --self-test - # ci-failure self-test (#9898). Fifth member of the PM self-test family # above, and until now the odd one out: `scripts/pm/ci-failure.mjs` — the # one command from "a check is red" to "here is the failing assertion" — @@ -2353,31 +2250,112 @@ jobs: node scripts/check-platform-object-tenancy-census.mjs --self-test node scripts/check-platform-object-tenancy-census.mjs - # #12236 one

per doc page. `DocsTitle` renders the frontmatter `title` - # as the page's

unconditionally (apps/docs/app/[lang]/docs/[[...slug]]/ - # page.tsx), so a body-level `# ` heading compiles to a SECOND one inside - # `DocsBody`. Measured on the dev server at /docs/data-modeling/objects - # before the sweep: two

, both reading "Object Metadata". 195 of the 403 - # pages were in that state, 138 of them with the two

identical. - # - # It lives next to check:doc-anchors because the two share the corpus and - # the heading model — this gate imports `stripFrontmatter` and - # `flattenHeadingText` from that one rather than re-modelling either, which - # is what makes the delete-vs-demote remedy right on the nine pages whose - # two headings differ only by inline code markup. - # - # The gate strips FENCED CODE before looking for a heading, and that is the - # load-bearing half: a naive `^# ` scan reports 205 files, ten of which are - # shell/YAML comments inside working examples. A fence-blind rule does not - # over-report by ten, it instructs the fixer to corrupt ten snippets. - # - # Two subtrees are excluded, each with a named owner (#12249 generated - # reference pages, #12250 release notes) and each self-retiring: the gate - # FAILS with DEAD-EXCLUSION when an excluded tree becomes clean, so the - # carve-outs cannot outlive their reasons. A run that judges zero pages - # fails too (#4690). - - name: One `

` per docs page - run: pnpm check:docs-single-h1 + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-docs-code: + # Lane 3 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). The always-on gates that ran from the docs page gates through the + # changeset-family self-tests: the docs and teaching-corpus ratchets, the + # symbol anchors, and the product-code guards (CLI, error codes, routes, + # logging, the objectui pin). + name: Lint · docs & code guards + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 4.0 minutes; 2 × 4.0 = 8.0, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # #12236 one

per doc page. `DocsTitle` renders the frontmatter `title` + # as the page's

unconditionally (apps/docs/app/[lang]/docs/[[...slug]]/ + # page.tsx), so a body-level `# ` heading compiles to a SECOND one inside + # `DocsBody`. Measured on the dev server at /docs/data-modeling/objects + # before the sweep: two

, both reading "Object Metadata". 195 of the 403 + # pages were in that state, 138 of them with the two

identical. + # + # It lives next to check:doc-anchors because the two share the corpus and + # the heading model — this gate imports `stripFrontmatter` and + # `flattenHeadingText` from that one rather than re-modelling either, which + # is what makes the delete-vs-demote remedy right on the nine pages whose + # two headings differ only by inline code markup. + # + # The gate strips FENCED CODE before looking for a heading, and that is the + # load-bearing half: a naive `^# ` scan reports 205 files, ten of which are + # shell/YAML comments inside working examples. A fence-blind rule does not + # over-report by ten, it instructs the fixer to corrupt ten snippets. + # + # Two subtrees are excluded, each with a named owner (#12249 generated + # reference pages, #12250 release notes) and each self-retiring: the gate + # FAILS with DEAD-EXCLUSION when an excluded tree becomes clean, so the + # carve-outs cannot outlive their reasons. A run that judges zero pages + # fails too (#4690). + - name: One `

` per docs page + run: pnpm check:docs-single-h1 # #9014 docs redirects: `apps/docs/redirects.mjs` is a 92-entry table of # PERMANENT (308) redirects, and before this step nothing in the repo read @@ -3607,6 +3585,110 @@ jobs: - name: Changeset-family gate self-tests run: pnpm check:changeset-gate-self-tests + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-ci-engine: + # Lane 4 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). The always-on gates that ran from the lockstep guard to the end of + # the single job, less the engine test-double gate (in `lint-eslint`): + # the release and workflow contracts, the CI and test-infrastructure + # self-tests (the aggregator-roster and required-context pins among + # them), the engine and packaging gates, and the issue-citation gate, + # which is why this lane alone reads issues and pull requests. + name: Lint · CI, release & engine gates + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 3.8 minutes; 2 × 3.8 = 7.6, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + 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 + # ⭐ 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 + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + # Lockstep package-count guard (#17039). `.changeset/config.json`'s # `fixed` array is the one true count of packages one stray `major` # promotes; check-changeset-fixed.mjs (release/RC workflows) already @@ -4536,23 +4618,6 @@ jobs: - name: Manifest repository.directory guard run: pnpm check:manifest-repository-directory - # Engine test-double contract gate (#4550, from #4434). A test double - # LOOSER than the implementation it replaces turns a green suite into no - # suite at all, silently, on exactly the paths a double was introduced - # for. #4434 is the worked example: DELETE /sharing/rules/:idOrName - # answered 500 for every rule and both address forms from the day it was - # written, while `deleteRule drops rule + all its grants` asserted success - # against a fake engine that accepted the one call shape ObjectQL.delete - # refuses. This holds every fake ObjectQL engine's `delete` to the real - # dispatch predicate — imported from @objectstack/objectql, not - # hand-mirrored, so it cannot drift — with the pre-existing fakes in a - # shrink-only, measured baseline. Static AST only, so it needs no build - # and belongs in this job. Runs its own --self-test first: the detector - # can be broken while every fake is fine, and a scan that quietly stops - # matching would report OK while reading nothing (#4868's family). - - name: Engine test-double contract gate - run: pnpm check:engine-double-contract - # WHERE-matcher conformance gate (#8494, from #7620). The read-side # sibling of the gate above, and the half its header lists as not # covered. #7620 corrected sixteen in-memory `matches(row, where)` @@ -5077,42 +5142,771 @@ jobs: - name: Duration-shaped spec keys carry their unit in the key name run: pnpm --filter @objectstack/spec check:duration-unit-keys - # ── NOT GATES. The two steps below judge nothing about the tree. ────── - # - # ⭐ One red in this job is a LOWER BOUND on the number of problems in the - # tree, not a count — every gate behind the failing one never executed, - # and a gate that never executed produced no output, which is the same - # shape a passing gate has. The GATE ORDER note above `jobs:` already - # names this quantity ("the unmeasured tail") and is tuned to shrink it; - # until now nothing printed it. These steps print it. - # - # ⛔ The fix is NOT to make the skipped gates run: that would undo the - # adjudicated fail-fast order. ⛔ Nor is it `continue-on-error`, which - # trades a red for silence. The job's conclusion is untouched. - # - # Measured before this was written, on run 34468505490 of this very - # workflow (job 102842713265, failed at step #55): the jobs API and the web - # UI DO mark the un-run steps `skipped` — 112 of them there — but they - # never count them, and they use that same word for a step its own `if:` - # turned off, so the one surface that says it cannot tell the two apart. - # The check-run `output` was null on title, summary and text alike, and the - # job log — what an API or agent reader gets — said nothing at all. So what - # is added here is a machine-readable COUNT, on all three surfaces. + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test - # Offline, no token, no network: drives the reporter's judge() over - # recorded jobs-API shapes, and checks that the step below still hands - # itself its own name. Wired as a step because an unrun self-test is a - # phantom check. Sub-second. - # - # ⭐ `if: failure()` for the same reason the step below carries it, and it - # is the whole negative control: a green run of this job must be BYTE - # IDENTICAL to what it was before any of this existed. A self-test that - # ran unconditionally would print one line on every green run — one line - # is still new noise, and this change is not allowed to cost any. It is - # exercised on exactly the runs its subject is used on, which in this repo - # is not a rare event. ⛔ Do not "fix" this by dropping the condition. - # It fails LOUDLY: it is a separate step, so a broken reporter shows up as - # its own red step rather than as a missing paragraph. + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-scoped-erasure: + # Lane 5 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). Path-scoped families (#16496): the engine query-options erasure + # ratchet, the heaviest scoped gate, and the declared-population gate. + # Like every scoped lane it runs the gate-family selector first (its full + # argument is in this lane) and pays its setup even when the selector + # skips everything in it: about a minute of runner, not of wall. + name: Lint · scoped · query-options erasure + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 4.3 minutes; 2 × 4.3 = 8.7, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # ── Gate-family selection (#16496) ─────────────────────────────────── + # After #16453 this job is the merge queue's critical path (median 1468 s + # over the ten merge-group runs measured for #16496; the PM dispatch-gates + # self-test alone 597 s), and none of its expensive steps read a + # merge group's file surface: a docs-only group paid the full battery. + # So on `merge_group` and `pull_request` the NINE scoped families below + # -- each step carrying `if: steps.gate-families.outputs. != 'skip'` + # -- run only when the changed paths touch the files that family reads. + # (A tenth, `migration_registry` (#19753), retired with its gate in + # #22554: the registry it compared is no longer committed.) + # `push` on main and the scheduled full run keep the whole battery: the + # script runs everything for any event it does not scope. + # + # #19498 added four of the nine (`entry_guard`, + # `declared_population_live`, `bare_root_worklist`, + # `self_test_workflow_commands`) and narrowed `pm_dispatch_gates`, on + # ruling #208 on #19491 (R4) and the maintainer's sentence quoted in the + # selector's header: the tooling's self-tests were 18.6 minutes of the + # 27.4-minute job a three-file `packages/spec` PR paid. ⛔ Product + # ratchets and censuses stay unconditional -- the tenancy and + # tenant-audit censuses, the engine gates and everything else here judge + # product code and are not in that ruling. + # + # ⭐ The invariant is FAIL-OPEN, and it holds at both layers. The script + # runs every family when the base cannot be resolved, the diff fails or + # is empty, a path is one it does not classify (a new top-level + # directory, an unlisted root file), or any change is a deletion, rename + # or type change; a family is skipped ONLY when every changed path is + # positively classified into a class that family's DECLARED read-set + # excludes. For the other four -- both ratchets, the corpus walk and the + # verify-lock self-test -- that read-set is what the family provably + # reads. For the five tooling self-tests it is the tool's OWN INPUTS, + # which is a weaker claim; it is argued where it is declared, in the + # selector's header. + # The `!= 'skip'` spelling means an ABSENT output -- the selector never + # ran, or wrote nothing -- also runs the step. Both halves are pinned by + # `scripts/ci/select-gate-families.selftest.sh` (`check:select-gate- + # families`, further down this job), which also reads THIS file and + # refuses any other `if:` spelling and any family the script does not + # decide. The per-family read-sets, and how each was measured, are in + # the script's header. + # + # The selection itself lives in scripts/ci/select-gate-families.sh: + # environment in, `$RUNNER_TEMP/gate-families.txt` + `$GITHUB_OUTPUT` + # out, and a ran/skipped table in the job summary (the seat reads it). + # This step only exports the event's fields, so the same script -- every + # branch of it -- runs locally and under its self-test. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + + # Engine query-options erasure ratchet (#4918). The slot-lookup rule above + # protects the service LOOKUP; this one protects what you pass to the + # service you looked up. `IDataEngine.find/findOne/count/aggregate` declare + # their options as `EngineQueryOptions` & co., and for an internal caller + # `tsc` is the ONLY channel enforcing them — the protocol's ingress + # normalizer never sees a direct engine call, and the options schemas are + # not `.strict()`, so an unknown key is silently DROPPED. #4674 is the + # bill: two queries sorted by `direction` instead of `order`, both with a + # `limit`, so both returned the OLDEST rows — audit history that never + # showed recent changes, and a search that truncated away fresh records. + # #4720 restored those two sites and #4721 closed the external callers; + # this stops the shape regrowing internally. Same ratchet mechanism as + # slot-lookup for the non-test residual, plus one aggregate decrease-only + # number for test code (a test whose subject IS off-contract engine input + # must be able to build it). Runs its own --self-test first. + # + # Scoped (#16496): the same population and inputs as the slot-lookup + # ratchet above, with `scripts/query-options-erasure-baseline.json`. + - name: Engine query-options erasure ratchet + if: steps.gate-families.outputs.query_options_erasure != 'skip' + run: pnpm check:query-options-erasure + + # The other half of the same failure (#13519). The step above holds that a + # declaration is still READABLE BY A TEXT SCANNER; this one holds that + # what it reads still names something. A family whose declared literals + # all reach zero tracked files prints as an ORDINARY SILENCE — byte for + # byte the output of a gate that declared nothing — so the derivation and + # the gate disagree about whether a population was declared and neither + # says so. Measured on the tree this landed against: two families, one of + # them a gate that sweeps every workflow file and appeared on no card that + # edited one. The rule is per-FAMILY, not per-literal: a gate may spell a + # slug or a sentinel path beside a live declaration, and only a whole + # declaration that reaches nothing is refused. No allowlist and no gate + # names — the stronger rule ("a gate that enumerates a directory must + # declare one") was implemented, measured at 86 findings over 114 + # enumerating gate files, and refused as an allowlist with a verdict + # attached. Reads the derivation once over the tracked corpus; ~5s of + # work, measured at 0.40 min as a step on run 35506407130. + # + # Scoped (#19498): it imports `discoverFamilies` and `trackedFiles` from + # the dispatch derivation, so its inputs are the workflow tree, every + # gate source that discovery resolves, and the tracked NAME set — and + # only a name that DISAPPEARS can turn a live declaration dead, which is + # a structural change the selector already runs everything for. A group + # confined to product source, tests, docs or changesets skips it here and + # pays for it on `push` to main and on the hourly run. + - name: A declared gate population reaches the tree + if: steps.gate-families.outputs.declared_population_live != 'skip' + run: pnpm check:declared-population-live + + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-scoped-slot-corpus: + # Lane 6 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). Path-scoped families (#16496): the slot-lookup ratchet, the + # entry-guard sweep and the comment-mask corpus walk. The slot-lookup + # ratchet is the gate the checkout step's `fetch-depth: 0` note names. + name: Lint · scoped · slot lookup & corpus + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 4.3 minutes; 2 × 4.3 = 8.6, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Gate-family selection (#16496) — the same step, once per lane that carries a + # scoped step: a step `if:` reads its OWN job's step outputs, so each such lane + # selects for itself. Argued in full in `lint-scoped-erasure`. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + + # Slot-lookup sweep ratchet (#4251). `pnpm lint` above bans erasing a + # service-lookup result to `any` across packages/, but the files still + # holding pre-existing sites are grandfathered by path — and an ignored + # file is ignored COMPLETELY, so new erasures added to one ride the old + # entry in silence. This re-measures those files with the grandfathering + # lifted and holds them to a per-file count, so the list can only shrink. + # + # Scoped (#16496): reads `packages/**/*.{ts,tsx,mts,cts}`, its baseline, + # `eslint.config.mjs` and the top-level `scripts/*.mjs` it imports; a + # group touching none of those skips it (selection step above). + - name: Slot-lookup ratchet + if: steps.gate-families.outputs.slot_lookup != 'skip' + run: pnpm check:slot-lookup + + # Every `scripts/**` entry guard goes through ONE predicate (#10086). + # The hand-typed forms of "did node run me, or did someone import me?" + # had drifted into ELEVEN spellings across 33 files, and NINE were wrong + # in the same invisible direction: node resolves symlinks for the module + # graph but leaves `process.argv[1]` as the caller typed it, so a script + # reached through a symlink compared two different paths, answered false, + # and did NOTHING — exit 0, no output. The CI wrappers here hold the + # child's exit STATUS only, so an inert child read as a green gate; the + # governed-surface register (`scripts/pm/check-governed-merges.mjs`) was + # among the affected, where the inert run and its "NOT governed, ordinary + # queue landing applies" verdict are the SAME exit code. + # The sweep alone would not have held — nothing stopped a twelfth + # spelling. This gate is the part that closes the class: only + # `scripts/invoked-as.mjs` may read `process.argv[1]`, and that module's + # own self-test drives a real probe through a real symlink. Rationale and + # the rejected behavioural-sweep alternative: the gate script's header. + # Scans ~115 files, no spawns; ~0.2s of work, measured at 0.62 min as a + # step on run 35506407130. + # + # Scoped (#19498): its population is `scripts/**` — its own + # ROOT_DIR_WATCH_HINTS declaration, held against the root it really walks + # by its own self-test — so a group that changes no file under scripts/ + # (and no root configuration) skips it. The selection step at the top of + # this job decides that, `push` on main and the hourly run keep it + # unconditional, and the ruling that authorizes moving a tooling + # self-test off the PR path is quoted in the selector's header. + - name: scripts/ entry guards go through one predicate + if: steps.gate-families.outputs.entry_guard != 'skip' + run: pnpm check:entry-guard + + # The comment-mask CORPUS sweep (#10640) — the other instrument for the + # module the two gates above delegate "is this span a comment, or code?" + # to. `js-comment-mask.mjs --self-test` pins the SHAPES someone wrote + # down; this parses every .{ts,tsx,mts,cts,js,mjs,cjs,jsx} file in the + # tree with @typescript-eslint/parser and diffs its comment ranges + # against the mask's, byte for byte. Neither subsumes the other, and that + # is measured in both directions on this tree rather than assumed: + # deleting the `{` counting inside `${...}` fails a pinned case and the + # sweep reads 0 disagreements over 4,741 files (the tree does not write + # that shape), while dropping `return` from REGEX_AFTER_KEYWORD passes all + # 23 pinned cases and the sweep names check-test-source-alias.mjs, where + # `return /(^|[^a-z])dist\//` is written today. + # The defect this would have caught shipped: 16 files disagreeing, 15 of + # them reading commented-out text as live code, 10,252 comment bytes in + # one file — and the instrument that found it lived in an agent's + # scratchpad, which is why re-deriving it was impossible until this file. + # WHY A HARD GATE ON EVERY PR, and not an on-demand flag: the sweep's + # verdict depends on the CORPUS as much as on the masker, so a PR that + # writes a shape the mask reads wrong turns ~20 source-scanning gates + # into liars on that file, on a PR that never touched the masker. That + # arrival is not detectable by any convention a human or an agent has to + # remember at the right moment — the failure mode this repo has already + # paid for at #4690 and again here. + # WHY A STEP AND NOT A JOB: a new job's `name:` is a required-context + # contract (scripts/check-required-contexts.mjs) that only a + # maintainer-present Settings change can enrol, and an unenrolled job is + # an advisory gate — #5617 verbatim. This job is already required. + # Invoked as `node` rather than `pnpm check:*`, same as the other + # `node scripts/…` steps in this lane: see the GATE INVOCATION IDIOM note + # at the top of this file. + # Runs its own comparator self-test first — 12 cases proving the + # comparison can still REPORT, because "0 files disagree" and "the + # comparison is broken" otherwise print the same line. + # Cost, measured on a contended 4-vCPU container: 4,741 files, 72 MB of + # source, 45–48 s, of which the parser is 45 s. The step prints CI's own + # number on every run. + # + # Scoped (#16496): the walk reads every `.ts .tsx .mts .cts .js .mjs + # .cjs .jsx` file in the tree, so a group that changes no file of those + # extensions (and no root configuration) skips it -- selection step at + # the top of this job. + - name: Comment mask agrees with a real parser over the whole corpus + if: steps.gate-families.outputs.comment_mask_corpus != 'skip' + run: node scripts/check-comment-mask-corpus.mjs + + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-scoped-tooling: + # Lane 7 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). Path-scoped families (#16496): the PM bare-root worklist + # self-test, the self-test workflow-command gate and the verify-lock + # entry-point self-test. + name: Lint · scoped · tooling self-tests + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 3.7 minutes; 2 × 3.7 = 7.5, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Gate-family selection (#16496) — the same step, once per lane that carries a + # scoped step: a step `if:` reads its OWN job's step outputs, so each such lane + # selects for itself. Argued in full in `lint-scoped-erasure`. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + + # PM bare-root worklist self-test (#10840). The step above proves the + # dispatch derivation still WORKS; this one proves the recorded triage of + # the gates that derivation structurally cannot see is still true of the + # tree. A gate whose population is spelled as a bare single-segment word + # builds no watch hint at all — not a dead hint, not a silent verdict, + # nothing — so it is unnameable by any dispatch brief and leaves no residue + # saying so. The worklist enumerates that species and carries one recorded + # verdict per row; this step fails when a verdict outlives its row (the + # shrink) or when a new gate joins the species unjudged. + # + # ⛔ It runs the SELF-TEST only, never the sweep as a verdict: recognising + # this species needs a heuristic over constant NAMES, and #10705 refused to + # put one on the path that derives every PR's gate list. Nothing in + # `dispatch-gates.mjs` reads the worklist, and no verdict in it reaches a + # dispatch prompt. + # + # Reads the workflow tree and every gate source once; ~0.5s of work, + # measured at 0.32 min as a step on run 35506407130. + # + # ⚠️ This step WAS unconditional, on the reasoning that a self-test which + # can be skipped is the gap moving rather than closing. #19498 scopes it + # anyway, and the trade is explicit rather than reasoned away: it imports + # the same derivation as the two steps above, so its inputs are the + # workflow tree and the gate sources, and on a group touching neither the + # gap does move — to `push` on main and the hourly full run, which keep + # the whole battery. Gate weakening is a maintainer floor; the sentence + # that authorizes this one is quoted in the selector's header. + - name: PM bare-root worklist self-test + if: steps.gate-families.outputs.bare_root_worklist != 'skip' + run: node scripts/pm/bare-root-worklist.mjs --self-test + + # Self-test output vs. the runner's command parser (#11886). Sibling of + # the step above and sharing its population: that one asks whether CI RUNS + # each self-test, this one asks what each self-test PRINTS while it does. + # + # The defect it closes was live on every PR in this repo. A line of + # `scripts/pm/ci-failure.mjs`'s self-test prose named a workflow-command + # token literally, inside backticks, as documentation of what that tool + # anchors on. The runner does not know a token is being quoted: it parsed + # it and minted `annotation_level: failure` on a check run whose + # conclusion was SUCCESS. On `d63b01436` that artifact was the ONLY + # annotation the `Lint & Repo Gates` run carried. A failure-level + # annotation on a green required check is a claim that something failed, + # and a repo that mints one on every PR is teaching its readers to skim + # exactly the surface other gates report findings into. + # + # ⚠️ INVISIBLE TO EVERY LOCAL RUN, which is the whole reason it is a gate: + # the text is ordinary prose on disk and in a terminal, and only a runner + # transforms it. It was found by reading one job's annotations through the + # API, not by anything in this file. + # + # Two facts were measured on real runs and the gate's shape follows them: + # the legacy `##[...]` form is parsed ANYWHERE in a printed line (the + # measured token sat at column 18 and was still consumed), while the + # `::...::` form is parsed only at LINE START (two mid-sentence ones print + # on every PR from `check-prerelease-pin-watch` and mint nothing). So the + # first detector is unanchored and the second is anchored — flagging inert + # prose would be a false positive the next author would be right to delete. + # + # It RUNS the self-tests rather than reasoning about their source, because + # "is this token printed?" is only answerable by printing it. A static + # prefilter picks which ones to run — over-selecting freely, since the + # verdict never comes from it — which is what keeps this step at ~14 s + # instead of the ~4 min the whole population costs. + # + # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias: + # see the GATE INVOCATION IDIOM note at the top of this file. Reads + # `scripts/` and `.github/workflows/` off disk and spawns the selected + # self-tests; no network. Measured at 0.48 min as a step on run + # 35506407130. + # + # Scoped (#19498): its declared population is + # `scripts/**/*.mjs`, `scripts/**/*.mts` and `scripts/**/*.sh`, and the + # rest of its read-set is the workflow tree and `.github/actions` it + # discovers the runnable self-tests from — so a group touching none of + # those skips it here and runs it on `push` to main and on the hourly + # full run. + - name: Self-test workflow-command gate + if: steps.gate-families.outputs.self_test_workflow_commands != 'skip' + run: | + node scripts/check-self-test-workflow-commands.mjs --self-test + node scripts/check-self-test-workflow-commands.mjs + + # Verify-lock entry-point self-test (#9661). `scripts/pm/os-verify-lock.sh` + # is the ONE way an agent takes the container's shared heavy-verify lock, + # and it is the enforcement of a rule that used to live only in prose: the + # acquisition budget os-dev.md declares is now unrepresentable above its + # cap, grants are ordered by a ticket file, and every run reports how long + # it held. Measured before it existed, in one container: five live waiters, + # three of them 6x over the declared cap, and the compliant ones were the + # ones not verifying — a convention whose violators win is not a convention. + # + # Nothing in CI *uses* the lock (GitHub runners are one job per machine), + # so this step exists to keep the mechanism honest rather than to serialise + # anything: 48 cases over the budget clamp, ticket liveness (dead pid, + # REUSED pid, over-age), fd hygiene, exit-code passthrough, and real + # multi-process contention — queue timeout, legacy free-hand coexistence in + # both directions, and three staggered waiters acquiring in arrival order. + # + # Since #10289 it also guards the BASH 3.2 FLOOR and the bounded-refusal + # rule, which is why the count moved: `/usr/bin/env bash` is 3.2.57 on + # macOS, and a bash 4+/5+ construct there does not fail loudly — it makes + # the acquisition deadline unreachable and turns the wait into an unbounded + # spin that prints no VERDICT at all. So the suite now scans the + # acquisition path for 4+/5+ constructs, runs a real acquisition with + # EPOCHSECONDS/EPOCHREALTIME unset and mapfile disabled, and asserts that + # an unusable host (no `flock`) and a stopped clock each REFUSE with a + # verdict and a non-zero exit rather than waiting forever. + # + # It runs entirely on a private lock under a temp dir; it never touches + # /tmp/os-heavy-verify.lock, so a runner and an agent container behave the + # same. No network; median 100 s over the ten merge-group runs measured + # for #16496. + # + # Scoped (#16496): the suite reads the script itself (`$SELF`) and a + # private temp dir, and its case (h) runs the real entry point from the + # repo root, which routes through filter_preflight -> + # scripts/pnpm-filter-targets.mjs --preflight and so reads the top-level + # scripts/*.mjs helpers that imports, pnpm-workspace.yaml and every + # workspace package.json. A group touching none of those skips it. That + # is not the exemption the self-tests above refuse (a label or a paths + # filter with no reader behind it): the selection step at the top of + # this job runs this step on every doubt, and `push` on main keeps it + # unconditional. + - name: Verify-lock entry-point self-test + if: steps.gate-families.outputs.verify_lock != 'skip' + run: bash scripts/pm/os-verify-lock.sh --self-test + + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-pm-dispatch-gates: + # Lane 8 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). One path-scoped family, alone: the PM dispatch-gates self-test. + # On `pull_request` and `merge_group` it runs the battery's fast tier; on + # `schedule` and `push` both tiers, the slow tier's daily home under the + # ruling (`scripts/pm/check-dispatch-gates.mjs` decides, from the event). + name: Lint · PM dispatch-gates + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites, where every run of this gate was BOTH tiers: max 15.5 + # minutes; 2 × 15.5 = 31.0, 30 on the grain. 20 + 30 = 50, under the 55 + # cap the `lint` job argues. Both tiers still run here on `schedule` and + # `push`, so the wall is sized for them, not for the fast tier. + timeout-minutes: 50 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Gate-family selection (#16496) — the same step, once per lane that carries a + # scoped step: a step `if:` reads its OWN job's step outputs, so each such lane + # selects for itself. Argued in full in `lint-scoped-erasure`. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + + # PM dispatch-gates self-test (#8162). `scripts/pm/dispatch-gates.mjs` + # derives the "local gates for this card" line of every dispatch prompt, + # and carried a 61-case --self-test that NO job ran: it executed only when + # a human or an agent typed it. A break in the extraction functions landed + # green and surfaced later as a prompt naming the wrong gate families — + # output that reads as correct, produced by the one tool whose purpose is + # to stop gate lists being memory-shaped. Same family as the + # `Changeset-family gate self-tests` step above. Until #16496 this step + # was deliberately UNCONDITIONAL -- no `if:`, no label read, no paths + # filter -- on the argument that an exemption is what a self-test must + # not have, or the gap moves. That argument was about an exemption a + # PATH FILTER or a LABEL would grant: a paths: trigger or a label read + # skips the step on a card that never touched the paths someone once + # listed, with no reader of what the test actually reads. What runs + # here now is different in kind and was the maintainer's call (#16496, + # 「同意你的建议,你负责执行派发所有可行的优化」): the selection step at + # the top of this job classifies every changed path against a declared + # read-set for this step, and skips it when no changed path is in that + # set. Every doubt runs it, the self-test of the selector pins that, and + # `push` on main keeps it unconditional. + # + # ⚠️ Since #19498 that read-set is NARROWER than what the battery reads, + # and the gap is stated rather than papered over. The read-set is the + # tool's own inputs: every workflow and composite action, every gate + # source under `scripts/**` and a workspace package's own `scripts/`, + # every `package.json`, `.claude/**`, `skills/**`, `AGENTS.md`, + # `CLAUDE.md` and root configuration. The battery ALSO reads the CONTENT + # of every JS/TS and `.sh` file in the tree (the compound-anchor census + # of `function ...SelfTest...(` declarations and the exposed-scratch-dir + # sweep of every mkdtempSync/mkdirSync caller, with the nested + # `.gitignore` files it consults) and the tracked NAME set it sweeps — + # which is why, until #19498, any added file anywhere ran it. On PR + # #19314 this step alone was 11.8 minutes of a 27.4-minute job for a + # three-file `packages/spec` diff, and ruling #208 on #19491 (R4) took + # those whole-tree reads off the PR path on the maintainer's sentence, + # quoted in the selector's header. ⛔ A defect in that wider set can now + # first appear on `main`; the push-on-main and hourly full runs are what + # bound it, and widening the skip further is again a maintainer call. + # + # The gate runs the SELF-TEST only. The live derivation + # (`node scripts/pm/dispatch-gates.mjs `) answers a question about a + # card's file surface, is print-only, and exits 0 on any completed run — + # there is no verdict in it for CI to hold, only a slow read of the whole + # workflow tree. Why the gate is its own file rather than pointing + # `check:pm-dispatch-gates` straight at the tool is measured and argued in + # `scripts/pm/check-dispatch-gates.mjs`'s header (the tool's self-test + # fixtures are path strings, and they become watch hints that fabricate + # MATCHED leads across most of the tree). + # + # ⛔ This paragraph deliberately carries NO cost figure (#12831). The line + # that used to close it — "Reads two files and walks packages/; ~0.3s" — + # described the 61-case self-test named at the top, and the part that grew + # since is exactly the part it denied: the live-tree cases run the same + # discovery the tool does (every workflow file, and then the source of + # every gate that discovery finds), build temporary git repositories and + # drive `changedPathsFromGit` against them, and sweep the tracked corpus. + # That is not "two files", and it is not a walk of packages/. The estimate + # went with the description rather than being refreshed: a replacement has + # to be read off a real CI run of THIS step, because a reading taken in an + # agent container is a reading of a contended box and not of a runner + # (#12528). A cost nobody re-measures ends up endorsing a decision it has + # no standing to endorse, which is what this one had become. No live + # counts stand here either, for the same reason — they rot the same way, + # and it is the SHAPE of the work, not its size, that the argument needs. + # + # ⛔ Cost was never what decided this step's conditionality, and the + # measured medians that motivated #16496 (597 s here, over ten + # merge-group runs) live in that card and its PR, not in this note. + # Losing the estimate is NOT an argument for a label read or a paths + # filter, and the read-set-scoped `if:` below is not one of those: it + # was a maintainer call (the first paragraph names it), and widening the + # skip -- any change to the classes the selector treats as inert for + # this family -- is again a maintainer call, taken in + # scripts/ci/select-gate-families.sh under its self-test. + - name: PM dispatch-gates self-test + if: steps.gate-families.outputs.pm_dispatch_gates != 'skip' + run: pnpm check:pm-dispatch-gates + + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - name: Unmeasured-gate-tail reporter self-test if: failure() run: node scripts/report-unmeasured-gate-tail.mjs --self-test From 78fac45e34fe90f26e1fdd375ed092839e36d46d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:23:32 +0000 Subject: [PATCH 4/5] ci(gates): the roster and required-context pins read the lint aggregate check-aggregator-roster registers lint / Lint & Repo Gates as a required aggregate, adds it to both positive controls, and anchors the typecheck refusal fixtures on typecheck's own roster (an unanchored pattern hit the first declaration in lint.yml, which is now lint's). check-required-contexts holds the pin to a job the Lint & Repo Gates context carries: lint itself, or a lane it both declares and needs, with no if: at step or lane level; fixtures pin declared-only, needed-only and the lane-level if: read. Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- scripts/check-aggregator-roster.mjs | 29 +++++-- scripts/check-required-contexts.mjs | 113 +++++++++++++++++++++++++--- 2 files changed, 124 insertions(+), 18 deletions(-) diff --git a/scripts/check-aggregator-roster.mjs b/scripts/check-aggregator-roster.mjs index 3a3a8920a23..ceea16f01d6 100644 --- a/scripts/check-aggregator-roster.mjs +++ b/scripts/check-aggregator-roster.mjs @@ -10,10 +10,11 @@ * * ## The gap this closes * - * Three of this repo's required contexts are aggregator jobs standing in for a + * Four of this repo's required contexts are aggregator jobs standing in for a * set of real jobs, each deciding its verdict from what it lists in `needs:`: * `test-gate` / `Test Core` and `dogfood-gate` / `Dogfood Regression Gate` in - * ci.yml, and `typecheck` / `TypeScript Type Check` in lint.yml. + * ci.yml, and `typecheck` / `TypeScript Type Check` and, since #22991, + * `lint` / `Lint & Repo Gates` in lint.yml. * * Nothing held those rosters to anything. The failure mode is one forgotten * line: @@ -67,7 +68,7 @@ * * Discovery is by declaration, so a NEW aggregator that adds the two keys is * covered without editing this file. `REQUIRED_AGGREGATORS` is the floor - * underneath that: the three jobs that carry a branch-protection-required + * underneath that: the four jobs that carry a branch-protection-required * context must each be found AND declared, so deleting the declaration to make * a red go away is itself red. * @@ -107,6 +108,8 @@ export const REQUIRED_AGGREGATORS = [ { workflow: 'ci.yml', job: 'test-gate', context: 'Test Core' }, { workflow: 'ci.yml', job: 'dogfood-gate', context: 'Dogfood Regression Gate' }, { workflow: 'lint.yml', job: 'typecheck', context: 'TypeScript Type Check' }, + // #22991: the single 193-step job became an aggregate over eight lanes. + { workflow: 'lint.yml', job: 'lint', context: 'Lint & Repo Gates' }, ]; /** A legal GitHub Actions job id. A comma or a stray quote fails here. */ @@ -495,6 +498,7 @@ async function selfTest() { battery('(2) POSITIVE CONTROL A, per aggregator'); const dropped = [ { label: 'typecheck loses a lane from needs:', file: 'lint.yml', member: 'typecheck-consumers', from: ' - typecheck-consumers\n', to: '' }, + { label: 'lint loses a lane from needs:', file: 'lint.yml', member: 'lint-pm-dispatch-gates', from: ' - lint-pm-dispatch-gates\n', to: '' }, { label: 'test-gate loses its matrix from needs:', file: 'ci.yml', member: 'test', from: ' needs: [test, filter]\n', to: ' needs: [filter]\n' }, { label: 'dogfood-gate loses the CLI pass from needs:', @@ -523,6 +527,7 @@ async function selfTest() { battery('(3) POSITIVE CONTROL B, per aggregator'); const phantom = [ { label: 'typecheck needs a job that was deleted', file: 'lint.yml', from: ' - typecheck-consumers\n', to: ' - typecheck-consumers\n - typecheck-ghost\n', ghost: 'typecheck-ghost' }, + { label: 'lint needs a job that was deleted', file: 'lint.yml', from: ' - lint-pm-dispatch-gates\n', to: ' - lint-pm-dispatch-gates\n - lint-ghost\n', ghost: 'lint-ghost' }, { label: 'test-gate needs a job that was deleted', file: 'ci.yml', from: ' needs: [test, filter]\n', to: ' needs: [test, filter, test-ghost]\n', ghost: 'test-ghost' }, { label: 'dogfood-gate needs a job that was deleted', @@ -561,13 +566,25 @@ async function selfTest() { // 5a. The declaration is unfindable: the whole env block is gone. battery('(5) REFUSALS -- the most important assertions in this file'); + // Anchored on the roster's FIRST MEMBER, not on the key alone: since #22991 lint.yml declares + // two rosters, and an unanchored pattern deletes whichever comes first in the file -- which + // silently retargeted this fixture at `lint` the day that job became an aggregate. const noDeclaration = fixture('typecheck loses its roster declaration', 'lint.yml', (s) => - s.replace(new RegExp(`\\n env:\\n ${MEMBERS_KEY}: [^\\n]*\\n`), '\n'), + s.replace(new RegExp(`\\n env:\\n ${MEMBERS_KEY}: typecheck-source-gates[^\\n]*\\n`), '\n'), ); assert( noDeclaration.problems.some((p) => p.includes("job 'typecheck'") && p.includes('declares no') && p.includes('refusing to report a pass')), `deleting an aggregator's declaration ⇒ REFUSAL naming the job -- got ${JSON.stringify(noDeclaration.problems)}`, ); + const noLintDeclaration = fixture('lint loses its roster declaration', 'lint.yml', (s) => + s.replace(new RegExp(`\\n env:\\n ${MEMBERS_KEY}: lint-eslint[^\\n]*\\n`), '\n'), + ); + assert( + noLintDeclaration.problems.some( + (p) => p.includes("job 'lint'") && p.includes("'Lint & Repo Gates'") && p.includes('declares no') && p.includes('refusing to report a pass'), + ), + `deleting the lint aggregate's declaration ⇒ REFUSAL naming the job and its required context -- got ${JSON.stringify(noLintDeclaration.problems)}`, + ); // 5b. The aggregator job itself is gone from the workflow. const noJob = fixture('test-gate is deleted from ci.yml', 'ci.yml', (s) => s.replace(/\n test-gate:\n/, '\n test-gate-renamed-away:\n')); @@ -578,7 +595,7 @@ async function selfTest() { // 5c. The declaration resolves to ZERO members. const emptyRoster = fixture('typecheck declares an empty roster', 'lint.yml', (s) => - s.replace(new RegExp(`${MEMBERS_KEY}: [^\\n]*`), `${MEMBERS_KEY}: ''`), + s.replace(new RegExp(`${MEMBERS_KEY}: typecheck-source-gates[^\\n]*`), `${MEMBERS_KEY}: ''`), ); assert( emptyRoster.problems.some((p) => p.includes('EMPTY') && p.includes('refusing to report a pass')), @@ -703,7 +720,7 @@ async function selfTest() { console.log( `✓ check-aggregator-roster --self-test: ${checked} assertions ` + `(baseline + a dropped member and a phantom needs: entry for each of the ${REQUIRED_AGGREGATORS.length} required aggregators + ` + - `six refusals + the --leg cross-check + the malformed-roster and laundered-non-member pins + the CI wiring).`, + `seven refusals + the --leg cross-check + the malformed-roster and laundered-non-member pins + the CI wiring).`, ); return SELF_TEST_VERDICT; diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 90f399376ab..0bd352fdcea 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -912,6 +912,37 @@ function pinSteps(job) { return steps.filter((step) => runCommands(step).some((command) => invokes(command, 'pnpm', PIN_SCRIPT))); } +/** + * The jobs whose verdict the required `lint` context (`Lint & Repo Gates`) carries, as + * `[id, job]` pairs: the `lint` job itself and, since #22991 made it an aggregate, every lane + * it BOTH declares in `OS_AGGREGATOR_MEMBERS` AND lists in `needs:`. + * + * Both, not either. A lane that is only declared is not waited on, and a lane that is only in + * `needs:` is the roster drift `check:aggregator-roster` reds — so neither is a place the + * pin may live and still be called wired. Read from the parsed document, never from text, for + * the reasons `pinSteps` lists. An unreadable document or a missing `lint` job answers no + * jobs at all, so every presence assertion over it goes red rather than vacuously green. + * + * @param {unknown} doc a parsed lint.yml + * @returns {Array<[string, Record]>} + */ +function jobsCarriedByLint(doc) { + const jobs = doc && typeof doc === 'object' ? doc.jobs : undefined; + if (!jobs || typeof jobs !== 'object') return []; + const lint = jobs.lint; + if (!lint || typeof lint !== 'object') return []; + const carried = [['lint', lint]]; + const env = lint.env && typeof lint.env === 'object' ? lint.env : {}; + const declared = String(env.OS_AGGREGATOR_MEMBERS ?? '').trim().split(/\s+/).filter(Boolean); + const needs = Array.isArray(lint.needs) ? lint.needs.map(String) : typeof lint.needs === 'string' ? [lint.needs] : []; + for (const id of declared) { + if (!needs.includes(id)) continue; + const job = jobs[id]; + if (job && typeof job === 'object') carried.push([id, job]); + } + return carried; +} + /** * Judge a registry against already-parsed workflows. * @@ -3115,9 +3146,11 @@ async function selfTest() { // A PRESENCE assertion about a required context's own wiring, which makes // its error direction a silent GREEN: whatever satisfies it wrongly leaves // this gate reporting that the pin runs while it does not. `pinSteps` reads - // `jobs.lint.steps` and asks each step's `run:` whether it INVOKES the - // script; the three text shapes that used to satisfy this are catalogued - // there and pinned below in both directions. + // a job's steps and asks each step's `run:` whether it INVOKES the script; + // the three text shapes that used to satisfy this are catalogued there and + // pinned below in both directions. Since #22991 the `lint` job is an + // aggregate that runs no gate itself, so "the required job" is the set + // `jobsCarriedByLint` reads: `lint` and the lanes it declares AND needs. battery('is the pin WIRED into the required job?'); const lintDoc = parse(sources['lint.yml']); const lintJob = lintDoc?.jobs?.lint; @@ -3129,11 +3162,15 @@ async function selfTest() { 'wiring: lint.yml must declare a readable `lint` job — this pin cannot say anything about a job it could not read (#4690)', ); /** The seam the fixtures below pin: lint.yml SOURCE in, "is the pin wired" out. */ - const pinWiredIn = (source) => pinSteps(parse(source)?.jobs?.lint).length > 0; + const pinWiredIn = (source) => jobsCarriedByLint(parse(source)).some(([, job]) => pinSteps(job).length > 0); assert( pinWiredIn(sources['lint.yml']), - 'wiring: lint.yml\'s `lint` job (the "Lint & Repo Gates" context) must run `pnpm check:required-contexts` — an unwired pin verifies nothing (#4690)', + 'wiring: lint.yml\'s `lint` job (the "Lint & Repo Gates" context), or a lane it declares and needs, must run `pnpm check:required-contexts` — an unwired pin verifies nothing (#4690)', ); + /** Every carried job that hosts the pin, with the pin steps it hosts. */ + const hosts = jobsCarriedByLint(lintDoc) + .map(([id, job]) => ({ id, job, steps: pinSteps(job) })) + .filter((h) => h.steps.length > 0); // Unchanged in intent (#10877 left this one alone deliberately: it is an // ABSENCE assertion and was never satisfiable by prose), but it consumed // the text slice above, so it is re-homed onto the same structural read. @@ -3142,9 +3179,16 @@ async function selfTest() { // does not get pinned. Non-vacuous because the presence assertion above // establishes there is at least one. assert( - pinSteps(lintJob).every((step) => !Object.prototype.hasOwnProperty.call(step, 'if')), + hosts.every((h) => h.steps.every((step) => !Object.prototype.hasOwnProperty.call(step, 'if'))), 'wiring: the required-context pin step must carry NO `if:` — whatever a condition reads is a way for a PR to arrange that this pin does not run on it', ); + // …and, since the pin moved into a lane (#22991), neither may the LANE: a job-level `if:` is + // the same arrangement one level up, and it is the one a step-level read cannot see. The + // aggregate itself is exempt by construction — its `if: always()` is what makes it report. + assert( + hosts.every((h) => h.id === 'lint' || !Object.prototype.hasOwnProperty.call(h.job, 'if')), + 'wiring: the lane hosting the required-context pin must carry NO job-level `if:` — a skipped lane is a pin that did not run', + ); // ── the wiring recognizer, in BOTH directions ──────────────────────────── // @@ -3218,8 +3262,53 @@ async function selfTest() { ' run: pnpm typecheck', ), ), - `wiring: the pin running in a job that is NOT \`lint\` does not wire \`Lint & Repo Gates\` (#10877)`, + `wiring: the pin running in a job that is NOT \`lint\`, nor a lane \`lint\` aggregates, does not wire \`Lint & Repo Gates\` (#10877)`, ); + // (q) ⭐ #22991: the pin in a LANE of the `lint` aggregate — declared AND needed — is wired. + // The checked-in lint.yml is this shape; the fixture pins the reading apart from it. + const aggregateFixture = ({ declare, need, laneIf = null }) => + lintFixture( + ' lint:', + ' name: Lint & Repo Gates', + ...(need ? [' needs: [lint-lane]'] : []), + ...(declare ? [' env:', ' OS_AGGREGATOR_MEMBERS: lint-lane'] : []), + ' if: always()', + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: Verify every lint lane succeeded', + ' run: node verify.mjs', + ' lint-lane:', + ...(laneIf ? [` if: ${laneIf}`] : []), + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: Required-context name pin', + ` run: pnpm ${PIN_SCRIPT}`, + ); + assert( + pinWiredIn(aggregateFixture({ declare: true, need: true })), + `wiring: the pin in a lane the \`lint\` aggregate declares AND needs IS wiring (#22991)`, + ); + // (r) …but a lane that is only DECLARED is not waited on: the aggregate can report before + // it ends, so its pin is not part of the verdict. + assert( + !pinWiredIn(aggregateFixture({ declare: true, need: false })), + 'wiring: the pin in a lane the aggregate declares but does not need is NOT wiring (#22991)', + ); + // (s) …and a lane that is only NEEDED is the roster drift `check:aggregator-roster` reds; + // this gate does not call a lane wired that the roster gate calls a defect. + assert( + !pinWiredIn(aggregateFixture({ declare: false, need: true })), + 'wiring: the pin in a lane the aggregate needs but does not declare is NOT wiring (#22991)', + ); + // (t) the job-level `if:` on the hosting lane, read the way the live assertion above reads it. + { + const laneIfDoc = parse(aggregateFixture({ declare: true, need: true, laneIf: "github.event_name != 'pull_request'" })); + const laneHosts = jobsCarriedByLint(laneIfDoc).filter(([, job]) => pinSteps(job).length > 0); + assert( + laneHosts.length === 1 && Object.prototype.hasOwnProperty.call(laneHosts[0][1], 'if'), + 'wiring: a job-level `if:` on the lane hosting the pin is SEEN by the hosts read (#22991) — the live no-`if:` assertion depends on it', + ); + } // (n) the genuine article, spelled the way lint.yml spells it. assert( pinWiredIn(lintJobFixture(' - name: Required-context name pin', ` run: pnpm ${PIN_SCRIPT}`)), @@ -3239,14 +3328,14 @@ async function selfTest() { const wiring = pkg.scripts?.['check:required-contexts'] ?? ''; assert(/check-required-contexts\.mjs --self-test/.test(wiring), 'wiring: `check:required-contexts` must run this file\'s --self-test first'); assert(/check-required-contexts\.mjs(?! --self-test)/.test(wiring), 'wiring: `check:required-contexts` must also run the real pin, not only the self-test'); - // The pin lives in a job it also pins. That is deliberate and worth stating: - // renaming the gate-family job turns this gate red under the NEW name, while - // the old required context stops reporting — the PR is blocked from both - // sides. That is also why the #9325 rename could not be self-serve: the + // The pin lives under a context it also pins — in a lane of the `lint` aggregate since + // #22991. That is deliberate and worth stating: renaming the aggregate turns this gate red + // under the NEW name, while the old required context stops reporting — the PR is blocked + // from both sides. That is also why the #9325 rename could not be self-serve: the // repo-side half is checkable here, the Settings half is not reachable at all. assert( REQUIRED_CONTEXTS.some((entry) => entry.workflow === 'lint.yml' && entry.job === 'lint'), - 'wiring: the job this gate runs in is itself registered, so a rename of it cannot be the one rename nothing notices', + 'wiring: the aggregate this gate runs under is itself registered, so a rename of it cannot be the one rename nothing notices', ); // The instruction-surface scan must be wired into the PIN, not only into // this self-test — a scan only the self-test exercises is #4690's phantom From f9c1ba065e76cad2c7ffbb077aec696b663bc93b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:25:52 +0000 Subject: [PATCH 5/5] ci(lint): comments name the step a moved neighbour referred to, and the per-lane order rule Positional references ("the step above") whose referent moved to another lane now name the step and its lane; the GATE ORDER note, the selector and the PM dispatch-gates step say what the split changed for them. Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- .github/workflows/lint.yml | 76 +++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0f963b834e7..836a1104403 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -157,6 +157,12 @@ concurrency: # the majority of red runs at no cost to a green one (whose wall clock is the # sum of its steps, and so is order-independent) — ⛔ so do not re-sort them # back among their topical neighbours as tidying. +# +# Since #22991 those gates run in eight parallel lanes behind the +# `Lint & Repo Gates` aggregate, and the rule holds PER LANE: each lane runs +# its gates sequentially and aborts at its first red, every step kept the +# relative order it had in the single job, and the two anchor gates still sit +# immediately after the six setup steps, at the top of `lint-repo-tooling`. # ───────────────────────────────────────────────────────────────────────────── jobs: @@ -655,7 +661,8 @@ jobs: run: pnpm check:adr-anchors # @objectstack/verify stand-in erasure guard (#6399). The third and - # narrowest member of the two above. `checkReadCoercion` / + # narrowest member of the slot-lookup and query-options erasure ratchets + # (in the scoped lanes since #22991). `checkReadCoercion` / # `checkDateBucketParity` take their driver STRUCTURALLY # (`CoercibleDriver` / `BucketableDriver`) so an out-of-tree driver — # cloud's driver-turso in remote mode — runs the identical contract @@ -721,7 +728,9 @@ jobs: run: pnpm check:gitlink-declared # The first three of these are the shared modules the two `scripts/**` - # routing gates below DELEGATE their design arguments to (#10608). Both of + # routing gates DELEGATE their design arguments to (#10608) — the parse + # guard below, and the entry guard in `lint-scoped-slot-corpus` since + # #22991. Both of # those gates are # SPELLING gates by deliberate design — `check:entry-guard` enforces that # only `scripts/invoked-as.mjs` may read `process.argv[1]`, and @@ -771,7 +780,7 @@ jobs: # rest of that remainder from here; that is a separate card. # # What runs here is the self-tests, not the modules' callers: for the first - # three the callers are the two gate steps below, which already run on + # three the callers are the two routing gates named above, which already run on # every PR; for `import-prerequisite` the callers are the 45 importing # gates, whose ordinary runs take the branch where the dependency IS # present and therefore observe none of what the self-test pins. @@ -846,7 +855,8 @@ jobs: # The #10573 sweep converted 32 call sites across 15 gates; it could not # stop the sixteenth being typed, and within the hour a new gate landed # with two raw calls in it — caught by this step, which is the whole - # argument for having it. Same shape as `check:entry-guard` above. + # argument for having it. Same shape as `check:entry-guard` (in + # `lint-scoped-slot-corpus` since #22991). # Also prints the parses OUTSIDE `scripts/**` (#10575) that it does not # govern, so its green line is read as a claim about `scripts/` and not # about the repository. @@ -1102,7 +1112,8 @@ jobs: run: pnpm check:pm-label-desc-cap # Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER - # (#12762). The step above proves the extractor still works; this one + # (#12762). The PM dispatch-gates self-test (its own lane since #22991) + # proves the extractor still works; this one # proves it still has something to extract. `extractWatchHints` reads # source text, so a declaration rewritten from a literal into a mapped # expression over the gate's population constant contributes NO hint at @@ -1136,7 +1147,8 @@ jobs: # PR-scoped blocking check in its own workflow — it needs a pull request # body to judge, which this job does not have — so what runs HERE is its # self-test, which is the half with a verdict independent of any PR. - # Unconditional, and not on the two steps above's borrowed reason — they + # Unconditional, and not on the borrowed reason of the declared-population + # and bare-root steps (the scoped lanes since #22991) — they # are scoped since #19498 and this one is not, because the cost that # bought that ruling is not here: this step is a tenth of a second, and a # self-test that runs only when someone remembers is a check whose @@ -3697,7 +3709,8 @@ jobs: # content/docs/protocol/backward-compatibility.mdx and one header # comment in scripts/publish-smoke-pack.mjs — so all three drifted # silently (69 written, 70 true) the day a package joined the group. - # Full-repo state, not diff-shaped, so — unlike the self-tests above — + # Full-repo state, not diff-shaped, so — unlike the changeset-family + # self-tests (`lint-docs-code`) — # this runs its real check every time, unconditionally. - name: Lockstep package-count guard run: pnpm check:lockstep-package-count @@ -4174,9 +4187,10 @@ jobs: - name: Gate-family selection self-test run: pnpm check:select-gate-families - # Aggregator roster gate (#10490). Three required contexts are aggregate + # Aggregator roster gate (#10490). Four required contexts are aggregate # jobs standing in for a set of real jobs — `Test Core` and `Dogfood - # Regression Gate` in ci.yml, `TypeScript Type Check` in this file — and + # Regression Gate` in ci.yml, `TypeScript Type Check` and, since #22991, + # `Lint & Repo Gates` in this file — and # each decides its verdict from what it lists in `needs:`. Nothing held # those rosters to anything. Add a lane to a workflow, forget the # `needs:` line, and the lane's check-run is ADVISORY (only the aggregate @@ -4619,7 +4633,8 @@ jobs: run: pnpm check:manifest-repository-directory # WHERE-matcher conformance gate (#8494, from #7620). The read-side - # sibling of the gate above, and the half its header lists as not + # sibling of the engine test-double gate (in `lint-eslint` since #22991), + # and the half its header lists as not # covered. #7620 corrected sixteen in-memory `matches(row, where)` # doubles that short-circuited on `$or` and discarded every sibling # equality key — turning a conjunction into a different query while the @@ -5271,6 +5286,14 @@ jobs: # out, and a ran/skipped table in the job summary (the seat reads it). # This step only exports the event's fields, so the same script -- every # branch of it -- runs locally and under its self-test. + # + # Since #22991 the gates run in lanes, and this step runs once in EACH + # lane that carries a scoped step: a step `if:` can read only its own + # job's step outputs, so the nine `if:` lines below stay byte-identical + # and each lane selects for itself. The script reads only the diff and + # is idempotent, and it cost 0 s at the median (1 s p90) over the 70 + # runs the `lint` job cites. A separate families job would instead put a + # second full-history checkout on the critical path of every scoped lane. - name: Select the gate families this run pays for id: gate-families env: @@ -5280,8 +5303,8 @@ jobs: run: | bash scripts/ci/select-gate-families.sh - # Engine query-options erasure ratchet (#4918). The slot-lookup rule above - # protects the service LOOKUP; this one protects what you pass to the + # Engine query-options erasure ratchet (#4918). The slot-lookup rule + # (`lint-scoped-slot-corpus` since #22991) protects the service LOOKUP; this one protects what you pass to the # service you looked up. `IDataEngine.find/findOne/count/aggregate` declare # their options as `EngineQueryOptions` & co., and for an internal caller # `tsc` is the ONLY channel enforcing them — the protocol's ingress @@ -5297,12 +5320,13 @@ jobs: # must be able to build it). Runs its own --self-test first. # # Scoped (#16496): the same population and inputs as the slot-lookup - # ratchet above, with `scripts/query-options-erasure-baseline.json`. + # ratchet, with `scripts/query-options-erasure-baseline.json`. - name: Engine query-options erasure ratchet if: steps.gate-families.outputs.query_options_erasure != 'skip' run: pnpm check:query-options-erasure - # The other half of the same failure (#13519). The step above holds that a + # The other half of the same failure (#13519). The watch-hint literal step + # (`lint-repo-tooling` since #22991) holds that a # declaration is still READABLE BY A TEXT SCANNER; this one holds that # what it reads still names something. A family whose declared literals # all reach zero tracked files prints as an ORDINARY SILENCE — byte for @@ -5422,7 +5446,7 @@ jobs: run: | bash scripts/ci/select-gate-families.sh - # Slot-lookup sweep ratchet (#4251). `pnpm lint` above bans erasing a + # Slot-lookup sweep ratchet (#4251). `pnpm lint` (`lint-eslint`) bans erasing a # service-lookup result to `any` across packages/, but the files still # holding pre-existing sites are grandfathered by path — and an ignored # file is ignored COMPLETELY, so new erasures added to one ride the old @@ -5467,7 +5491,7 @@ jobs: run: pnpm check:entry-guard # The comment-mask CORPUS sweep (#10640) — the other instrument for the - # module the two gates above delegate "is this span a comment, or code?" + # module the entry and parse guards delegate "is this span a comment, or code?" # to. `js-comment-mask.mjs --self-test` pins the SHAPES someone wrote # down; this parses every .{ts,tsx,mts,cts,js,mjs,cjs,jsx} file in the # tree with @typescript-eslint/parser and diffs its comment ranges @@ -5603,7 +5627,8 @@ jobs: run: | bash scripts/ci/select-gate-families.sh - # PM bare-root worklist self-test (#10840). The step above proves the + # PM bare-root worklist self-test (#10840). The PM dispatch-gates + # self-test proves the # dispatch derivation still WORKS; this one proves the recorded triage of # the gates that derivation structurally cannot see is still true of the # tree. A gate whose population is spelled as a bare single-segment word @@ -5625,7 +5650,7 @@ jobs: # ⚠️ This step WAS unconditional, on the reasoning that a self-test which # can be skipped is the gap moving rather than closing. #19498 scopes it # anyway, and the trade is explicit rather than reasoned away: it imports - # the same derivation as the two steps above, so its inputs are the + # the same derivation as the dispatch-gates and watch-hint literal steps, so its inputs are the # workflow tree and the gate sources, and on a group touching neither the # gap does move — to `push` on main and the hourly full run, which keep # the whole battery. Gate weakening is a maintainer floor; the sentence @@ -5635,7 +5660,8 @@ jobs: run: node scripts/pm/bare-root-worklist.mjs --self-test # Self-test output vs. the runner's command parser (#11886). Sibling of - # the step above and sharing its population: that one asks whether CI RUNS + # the self-test wiring gate (`lint-repo-tooling`) and sharing its + # population: that one asks whether CI RUNS # each self-test, this one asks what each self-test PRINTS while it does. # # The defect it closes was live on every PR in this repo. A line of @@ -5865,6 +5891,13 @@ jobs: # first appear on `main`; the push-on-main and hourly full runs are what # bound it, and widening the skip further is again a maintainer call. # + # ⚠️ Since #22991 the battery is TIERED by event, inside the gate rather + # than here: on `pull_request` and `merge_group` `pnpm check:pm-dispatch-gates` + # runs the fast tier; on `schedule`, `push` and any other event it runs + # both. Every case still runs at least hourly on the full run, and a + # slow-tier red is a red on `main`. The step below is unchanged; the + # decision and its argument live in `scripts/pm/check-dispatch-gates.mjs`. + # # The gate runs the SELF-TEST only. The live derivation # (`node scripts/pm/dispatch-gates.mjs `) answers a question about a # card's file surface, is print-only, and exits 0 on any completed run — @@ -7878,8 +7911,9 @@ jobs: # moment a lane moves, and cannot say WHICH lane went missing. # # Two things read this list, which is why it is real YAML and not a comment: - # - `node scripts/check-aggregator-roster.mjs` (statically, in the lint - # job) asserts it equals this job's `needs:` EXACTLY, in both + # - `node scripts/check-aggregator-roster.mjs` (statically, in the + # `lint-ci-engine` lane of `Lint & Repo Gates` since #22991) asserts it + # equals this job's `needs:` EXACTLY, in both # directions. A fifth lane added to this workflow and forgotten here # would otherwise ride green as an ADVISORY check-run behind the # required "TypeScript Type Check" context — a job that appears in the