diff --git a/.github/workflows/half-state-patrol.yml b/.github/workflows/half-state-patrol.yml index 76386f8ab3a..63191a148c8 100644 --- a/.github/workflows/half-state-patrol.yml +++ b/.github/workflows/half-state-patrol.yml @@ -356,8 +356,8 @@ jobs: # The judge's own cases first, and the sweep only if they hold: this # step WRITES to other people's cards, and a broken predicate that # still runs is the one failure mode that cannot be undone by the next - # run. `check:pm-closed-card-sweep` is the same command under a dev - # -facing name; this is the invocation CI holds. + # run. It has no root alias: this job sets up no pnpm, so this line is + # the one definition (the GATE INVOCATION IDIOM note in lint.yml). set +e node scripts/pm/sweep-closed-cards.mjs --self-test > "$RUNNER_TEMP/closed-cards-selftest.log" 2>&1 selftest=$? diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1aed00c6800..a381e1a1d74 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -108,48 +108,40 @@ concurrency: # ───────────────────────────────────────────────────────────────────────────── # ⭐ GATE INVOCATION IDIOM — stated ONCE, here. The steps below point at it. # -# Most gate steps in this file run their script directly: +# A gate step runs its root `package.json` alias: # -# run: node scripts/check-.mjs +# run: pnpm check: # -# rather than through a `pnpm check:*` alias. That is a deliberate in-repo -# idiom with its own justification, not a workaround: +# so `package.json` holds the one definition of the command, and a fix to the +# alias is a fix to CI (#26091, #26086). A gate that is new, or whose step is +# being rewired, gets a `check:*` key and CI calls that key. The gates that pin +# their own wiring (check-aggregator-roster, check-ci-filter-parity, +# check-doc-frontmatter, check-docs-nav-label, check-system-context-census) +# assert both halves in their self-tests: this file runs `pnpm check:`, +# and the root alias is exactly `--self-test` then the gate. So the step and the +# alias cannot drift apart without a red. # -# * Many gate steps in this file already use it — it is the local precedent. -# * `scripts/pm/dispatch-gates.mjs` derives gate families from EITHER -# spelling, so the direct form loses no discovery. -# * It adds no key to the root manifest. -# * It is pinned rather than incidental: check-aggregator-roster, -# check-ci-filter-parity and check-doc-frontmatter each assert in their own -# self-test that `node ` appears in THIS file, so silently rewiring -# one of them to an alias reddens its gate. +# A step still runs `node scripts/…` directly in three cases, and only these: # -# ⚠️ It is NOT because root `package.json` is off limits. This file used to say -# that, in one shape or another, at nineteen separate sites, and it is FALSE. -# The declared territory of the #9465 @changesets/cli v3 lane inside that file -# is scoped by the lane's own parenthetical, verbatim from the issue body: +# * it runs a DIFFERENT invocation from any alias: other arguments +# (`--base "$MERGE_BASE"`, `--advisory`, `--census`), or one leg only (the +# self-test half of a gate whose scan runs elsewhere). An alias is a copy +# only when it is the same command, and a copy is what this rule removes; +# * its job sets up no pnpm (an install-free job elsewhere, such as +# check-links.yml or board-snapshot.yml, keeps the direct form); +# * it predates this rule and its script has no alias yet. Those steps are +# not swept (#25919 「存量不横扫」); each gains an alias when its step is +# next rewired. Their comments say "invoked as `node` rather than through a +# `pnpm check:*` alias" and point here. # -# root `package.json` (the `@changesets/cli` range and the `version` script) +# `scripts/pm/dispatch-gates.mjs` derives gate families from either spelling, +# so neither form loses discovery. # -# That parenthetical is SCOPING, not illustrative: one dependency range and one -# script key. It does not cover the file, and it does not cover a new `check:*` -# key — so adding `check:` to the root manifest would have been allowed -# all along. The steps below take the direct form on the merits listed above; -# they are not fenced out of the alternative. -# -# Stated once here, and POINTED AT rather than restated, because the over-broad -# reading propagates by copying — measured, not supposed. A dev agent wrote the -# same sentence into a new comment and a new script's docblock and said plainly: -# "I copied the phrasing from neighbouring comments instead of checking it." -# The count was 15 sites when #10894 was filed on 2026-08-21 and 19 by -# 2026-08-24, four of them added AFTER the card was filed and two of those -# within ninety minutes of it being picked up. Nineteen CORRECTED copies would -# re-arm the identical mechanism: a claim that reads as verified because it -# reads exactly like the verified ones beside it. So there is one statement of -# this idiom — this one — and every step refers to it. -# -# Changing this note? It is prose, and no gate can red on a wrong sentence -# here; that is precisely how nineteen wrong ones accumulated. Check any claim +# ⚠️ Root `package.json` was never off limits. The #9465 @changesets/cli v3 +# lane's territory in that file is one dependency range and one script key, +# verbatim from that issue: root `package.json` (the `@changesets/cli` range +# and the `version` script). A new `check:*` key was always allowed; the +# opposite reading once spread to nineteen comments by copying. Check any claim # about the #9465 fence against #9465's own body before you write it down. # ───────────────────────────────────────────────────────────────────────────── @@ -1818,9 +1810,7 @@ jobs: - name: Docs frontmatter parses id: docs-frontmatter-parses continue-on-error: true - run: | - node scripts/check-doc-frontmatter.mjs --self-test - node scripts/check-doc-frontmatter.mjs + run: pnpm check:doc-frontmatter # The docs site's short sidebar label (`navTitle`) reaches the PAGE TREE # and nothing else (#12311). A doc page's `title` serves the SERP title, @@ -1845,15 +1835,12 @@ jobs: # an upgrade does the gate says migrate instead of letting ours become the # thing nobody migrated. # - # Invoked as `node` rather than a `pnpm check:*` alias: see the GATE - # INVOCATION IDIOM note at the top of this file. Filesystem read plus one - # type-stripped import; sub-second. + # Runs its root alias, per the GATE INVOCATION IDIOM note at the top of + # this file. Filesystem read plus one type-stripped import; sub-second. - name: Docs nav label stays out of the title surfaces id: docs-nav-label-stays-out-of-the-title continue-on-error: true - run: | - node scripts/check-docs-nav-label.mjs --self-test - node scripts/check-docs-nav-label.mjs + run: pnpm check:docs-nav-label # #13548 tenant-audit census. `content/docs/permissions/tenant-audit-census.mdx` # carries the population `SqlDriver.auditMissingTenant` acts on: every @@ -2024,9 +2011,9 @@ jobs: # job's check-run is advisory with no signal anywhere (#5617), while a # failing step here is read by everyone — and enforcement later needs # only the flag flip, not a Settings change. - # Invoked as `node` rather than a `pnpm check:*` alias, same as the - # frontmatter step above: see the GATE INVOCATION IDIOM note at the top of - # this file. + # Invoked as `node` rather than a `pnpm check:*` alias (it has none yet, + # and its scan runs `--advisory`): see the GATE INVOCATION IDIOM note at + # the top of this file. - name: Route spellings taught in prose match the ledgers (advisory) id: route-spellings-taught-in-prose-match continue-on-error: true @@ -2418,69 +2405,36 @@ jobs: # #13788 (1,647 platform-checklist citations) joins by registering its own # corpus — ⛔ not by forking the resolver. # - # Invoked as `node` rather than a pnpm alias per the GATE INVOCATION IDIOM - # at the top of this file; the gate's own --self-test asserts BOTH lines - # below are present, so rewiring it silently reddens it. - # - # ⚠️ The two self-tests go through a `run_self_test` COLLECTOR, not a bare - # sequence. Under `bash -e` the first non-zero exit aborts the step, so a - # chained second self-test is neither green nor red — it never runs - # (#10814). The collector runs both unconditionally and fails at the end - # naming every one that broke. - # - # ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic. - # `check-step-collectors` drives this very block under a real `bash -e` - # with a STUB planted at each collected command's script path, and holds - # "all green => every command runs" as an equality. A trailing - # `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes - # that same stub, so the harness counts 3 executions against 2 collected - # commands and the gate reds (3/2) — which is exactly what it did here. - # Both existing collectors in this file carry self-tests ONLY, for the - # same reason. Keep the sweep out of the collector block. - - name: Symbol-anchor gate self-tests - id: symbol-anchor-gate-self-tests - continue-on-error: true - run: | - failed="" - run_self_test() { - echo "-- $*" - if "$@"; then - echo "PASS $*" - else - echo "FAIL $*" - failed="${failed} $*"$'\n' - fi - return 0 - } - run_self_test node scripts/symbol-anchors.mjs --self-test - run_self_test node scripts/check-adr-symbol-anchors.mjs --self-test - run_self_test node scripts/check-scripts-symbol-anchors.mjs --self-test - run_self_test node scripts/check-spec-docblock-symbol-anchors.mjs --self-test - if [ -n "$failed" ]; then - echo "" - echo "Symbol-anchor self-tests — the following FAILED:" - printf "%s" "$failed" - exit 1 - fi + # Every step here runs a root alias, per the GATE INVOCATION IDIOM note at + # the top of this file: `pnpm check:symbol-anchors` is the shared + # resolver's `--self-test`, and each `pnpm check:-symbol-anchors` + # is that corpus's own `--self-test`, then its sweep. One step each, each + # with an `id:` and `continue-on-error`, so a red one never hides the + # others — the job the `run_self_test` collector that used to hold the + # four self-tests did (#10814). Each corpus gate's own --self-test asserts + # its step and its alias, so rewiring one silently reddens it. The + # resolver keeps its own step and alias because it declares no path + # population of its own (its `dispatch-gates: no-path-population` marker) + # while every corpus does. + - name: Symbol-anchor resolver self-test + id: symbol-anchor-resolver-self-test + continue-on-error: true + run: pnpm check:symbol-anchors - name: ADR symbol anchors resolve (no line numbers survive) id: adr-symbol-anchors-resolve-no-line-numbers continue-on-error: true - run: node scripts/check-adr-symbol-anchors.mjs + run: pnpm check:adr-symbol-anchors # The SECOND corpus (#15765), and it is a `defineCorpus` registration over - # `scripts/**` comment prose — ⛔ not a second resolver. Its own sweep is a - # separate step for the same `check-step-collectors` reason stated above: - # a production sweep inside the collector block re-invokes the stub the - # harness plants and the count comes out 4/3. + # `scripts/**` comment prose — ⛔ not a second resolver. - name: scripts/** symbol anchors resolve (no line citations on tracked targets survive) id: scripts-symbol-anchors-resolve-no-line continue-on-error: true - run: node scripts/check-scripts-symbol-anchors.mjs + run: pnpm check:scripts-symbol-anchors # The THIRD corpus (#17065) — `packages/spec/src/**` doc blocks, again a - # `defineCorpus` registration and ⛔ not a second resolver. Separate sweep - # step for the same `check-step-collectors` reason stated above. + # `defineCorpus` registration and ⛔ not a second resolver. # # ⭐ Its exit contract is a PINNED DAY-ONE RESIDUAL, not a clean tree: the # census (7 findings across 6 of 1,317 files, taken in reporting mode @@ -2492,7 +2446,7 @@ jobs: - name: packages/spec/src/** doc-block symbol anchors resolve (no NEW line citations on tracked targets) id: packages-spec-src-doc-block-symbol-anchors continue-on-error: true - run: node scripts/check-spec-docblock-symbol-anchors.mjs + run: pnpm check:spec-docblock-symbol-anchors # NOTE: the standing platform test checklist (docs/qa/platform-checklist/) # is validated by `pnpm check:platform-checklist`, but by MAINTAINER @@ -3429,9 +3383,7 @@ jobs: - name: Aggregator roster gate id: aggregator-roster-gate continue-on-error: true - run: | - node scripts/check-aggregator-roster.mjs --self-test - node scripts/check-aggregator-roster.mjs + run: pnpm check:aggregator-roster # Rationale: `scripts/ci/lane-verdict.mjs` header (#23191). The verdict # every lane above and below ends with reads each gate's `outcome`, never @@ -3562,9 +3514,7 @@ jobs: - name: CI filter parity (cross-package Layer C) id: ci-filter-parity-cross-package-layer-c continue-on-error: true - run: | - node scripts/check-ci-filter-parity.mjs --self-test - node scripts/check-ci-filter-parity.mjs + run: pnpm check:ci-filter-parity # The READING half of the gate above (#10046). `--union-into` appends the # cross-package scans to the `turbo ls` document ci.yml is about to shard, @@ -4039,7 +3989,7 @@ jobs: # # ⛔ The manifest alias is NOT the verdict. It runs `--self-test` and # nothing else — the shape every credential-needing gate in this manifest - # uses (`check:pm-half-states`, `check:pm-closed-card-sweep`), because a + # uses (`check:pm-half-states`, for one), because a # live mode needs a board and a credential. Wiring the alias ALONE would # run the self-test twice and scan nothing. Both spellings are here on # purpose, self-test first: `check-self-test-wired` requires CI to run the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb385f671a3..041b52c77f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -299,7 +299,7 @@ jobs: # (the publish lane runs it again — a gate on one lane is not a gate on # the other). - name: Verify Changesets "fixed" group covers every public package - run: node scripts/check-changeset-fixed.mjs + run: pnpm check:changeset-fixed # ══════════════════════════════════════════════════════════════════════ # POST-VERSION VALIDATION — THE ONLY GATE THE RELEASE COMMIT EVER PASSES @@ -1816,7 +1816,7 @@ jobs: ${{ runner.os }}-turbo-${{ github.job }}- - name: Verify Changesets "fixed" group covers every public package - run: node scripts/check-changeset-fixed.mjs + run: pnpm check:changeset-fixed # ────────────────────────────────────────────────────────────────────── # ⛔ TOMBSTONE — no objectui pin is read, built or checked here, and none diff --git a/.github/workflows/validate-deps.yml b/.github/workflows/validate-deps.yml index d8fab0a44b0..683046a350a 100644 --- a/.github/workflows/validate-deps.yml +++ b/.github/workflows/validate-deps.yml @@ -61,8 +61,9 @@ jobs: with: install-args: --prefer-offline + # Through its root alias, so its --self-test runs first (#26091). - name: Verify Changesets "fixed" group covers every public package - run: node scripts/check-changeset-fixed.mjs + run: pnpm check:changeset-fixed # pnpm overrides pin what THIS workspace resolves, but they do not ship # with published packages — downstream installs see only the declared diff --git a/package.json b/package.json index 208b2b2614d..aab6db3d8f5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "check:widget-option-census": "node scripts/check-widget-option-census.mjs --self-test && node scripts/check-widget-option-census.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs", + "check:doc-frontmatter": "node scripts/check-doc-frontmatter.mjs --self-test && node scripts/check-doc-frontmatter.mjs", + "check:docs-nav-label": "node scripts/check-docs-nav-label.mjs --self-test && node scripts/check-docs-nav-label.mjs", "check:docs-single-h1": "node scripts/check-docs-single-h1.mjs --self-test && node scripts/check-docs-single-h1.mjs", "check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs", "check:docs-redirects": "node scripts/check-docs-redirects.mjs --self-test && node scripts/check-docs-redirects.mjs", @@ -80,7 +82,6 @@ "check:issue-citations": "node scripts/check-issue-citations.mjs --self-test", "check:pm-record-recognisers": "node scripts/pm/record-recognisers.mjs --self-test", "check:pm-prior-rulings": "node scripts/pm/check-prior-rulings.mjs --self-test", - "check:pm-closed-card-sweep": "node scripts/pm/sweep-closed-cards.mjs --self-test", "check:pm-stale-finding": "node scripts/pm/sweep-stale-finding.mjs --self-test", "check:pm-write-pace": "node scripts/pm/write-pace.mjs --self-test", "check:pm-fleet-token": "node scripts/pm/fleet-token.mjs --self-test", @@ -110,9 +111,10 @@ "check:workspace-manifest-cycles": "node scripts/check-workspace-manifest-cycles.mjs --self-test && node scripts/check-workspace-manifest-cycles.mjs", "check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs", "check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs", - "check:adr-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs", - "check:scripts-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs", - "check:spec-docblock-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-spec-docblock-symbol-anchors.mjs --self-test && node scripts/check-spec-docblock-symbol-anchors.mjs", + "check:symbol-anchors": "node scripts/symbol-anchors.mjs --self-test", + "check:adr-symbol-anchors": "node scripts/check-adr-symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs", + "check:scripts-symbol-anchors": "node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs", + "check:spec-docblock-symbol-anchors": "node scripts/check-spec-docblock-symbol-anchors.mjs --self-test && node scripts/check-spec-docblock-symbol-anchors.mjs", "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs --self-test && node scripts/check-platform-checklist.mjs", "gen:checklist-status": "node scripts/gen-checklist-status.mjs --self-test && node scripts/gen-checklist-status.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs --self-test && node scripts/check-org-identifier.mjs", @@ -144,6 +146,7 @@ "check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs", "check:dev-prereqs": "node scripts/check-dev-prereqs.mjs --self-test && node scripts/check-dev-prereqs.mjs", "check:empty-changeset": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-empty-changeset.mjs", + "check:changeset-fixed": "node scripts/check-changeset-fixed.mjs --self-test && node scripts/check-changeset-fixed.mjs", "check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs", "check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-changeset-no-major.mjs --self-test", "check:lockstep-package-count": "node scripts/check-lockstep-package-count.mjs --self-test && node scripts/check-lockstep-package-count.mjs", @@ -163,6 +166,8 @@ "check:select-gate-families": "bash scripts/ci/select-gate-families.selftest.sh", "check:shard-attestation": "node scripts/check-shard-attestation.mjs --self-test && node scripts/check-shard-attestation.mjs", "check:required-contexts": "node scripts/check-required-contexts.mjs --self-test && node scripts/check-required-contexts.mjs", + "check:aggregator-roster": "node scripts/check-aggregator-roster.mjs --self-test && node scripts/check-aggregator-roster.mjs", + "check:ci-filter-parity": "node scripts/check-ci-filter-parity.mjs --self-test && node scripts/check-ci-filter-parity.mjs", "check:cross-package-test-inputs": "node scripts/check-cross-package-test-inputs.mjs --self-test && node scripts/check-cross-package-test-inputs.mjs", "check:live-db-isolation": "node scripts/check-live-db-isolation.mjs --self-test && node scripts/check-live-db-isolation.mjs", "check:tier-file-adoption": "node scripts/check-tier-file-adoption.mjs --self-test && node scripts/check-tier-file-adoption.mjs", diff --git a/scripts/check-adr-symbol-anchors.mjs b/scripts/check-adr-symbol-anchors.mjs index a629aa344f4..e0f5ddb99bf 100644 --- a/scripts/check-adr-symbol-anchors.mjs +++ b/scripts/check-adr-symbol-anchors.mjs @@ -331,8 +331,14 @@ export function selfTest() { // 4. The gate is wired to run. A gate nothing invokes is this repo's most // carded defect class, and renaming a step silently detaches it. const workflow = readFileSync('.github/workflows/lint.yml', 'utf8'); - check(workflow.includes('node scripts/check-adr-symbol-anchors.mjs'), 'lint.yml must invoke this gate'); - check(workflow.includes('node scripts/check-adr-symbol-anchors.mjs --self-test'), 'lint.yml must invoke this gate\'s --self-test'); + // CI runs the root alias (lint.yml's GATE INVOCATION IDIOM note), so pin + // the step AND the alias: this self-test, then the sweep. + check(/^\s*run: pnpm check:adr-symbol-anchors\s*$/m.test(workflow), 'lint.yml must run this gate through `pnpm check:adr-symbol-anchors`'); + const alias = JSON.parse(readFileSync('package.json', 'utf8')).scripts?.['check:adr-symbol-anchors']; + check( + alias === 'node scripts/check-adr-symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs', + `root package.json's check:adr-symbol-anchors must run this gate's --self-test, then the sweep -- got ${JSON.stringify(alias)}`, + ); // 5. The census declaration is intact, INCLUDING the one-way error direction // the ruling ordered recorded (point 5). diff --git a/scripts/check-aggregator-roster.mjs b/scripts/check-aggregator-roster.mjs index 9ecf16566d0..fca48f413ee 100644 --- a/scripts/check-aggregator-roster.mjs +++ b/scripts/check-aggregator-roster.mjs @@ -663,8 +663,22 @@ async function selfTest() { { const lint = sources['lint.yml']; const self = 'scripts/check-aggregator-roster.mjs'; - assert(lint.includes(`node ${self}\n`), `wiring: lint.yml invokes ${self} directly (lint.yml's GATE INVOCATION IDIOM note, not a package.json fence)`); - assert(lint.includes(`node ${self} --self-test`), 'wiring: lint.yml runs the --self-test half too'); + // CI runs the root alias (lint.yml's GATE INVOCATION IDIOM note), so the + // alias is where both legs live: pin the step AND the alias value. + assert( + /^\s*run: pnpm check:aggregator-roster\s*$/m.test(lint), + `wiring: lint.yml runs ${self} through its root alias \`pnpm check:aggregator-roster\``, + ); + let alias = null; + try { + alias = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts?.['check:aggregator-roster'] ?? null; + } catch (err) { + failures.push(`cannot read root package.json to verify wiring: ${err?.code ?? err?.message}`); + } + assert( + alias === `node ${self} --self-test && node ${self}`, + `wiring: root package.json's check:aggregator-roster runs the --self-test leg, then the gate -- got ${JSON.stringify(alias)}`, + ); } // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── diff --git a/scripts/check-changeset-fixed.mjs b/scripts/check-changeset-fixed.mjs index db4910a521f..205d8ee3ad0 100644 --- a/scripts/check-changeset-fixed.mjs +++ b/scripts/check-changeset-fixed.mjs @@ -5,6 +5,7 @@ * released out of lockstep with the rest of the monorepo. * * Run: node scripts/check-changeset-fixed.mjs + * node scripts/check-changeset-fixed.mjs --self-test # the rules against fixtures, no tree read * * Exits with code 1 (and a clear diff) if: * - A public (non-private) workspace package is missing from the @@ -113,7 +114,169 @@ export function judge(config, { publicNames, privateNames }, declared = PRIVATE_ return { missing, stale, declaredProblems }; } +// ── --self-test ────────────────────────────────────────────────────────────── +// +// Shape copied from `scripts/check-node-version.mjs` (AGENTS.md, "Writing a +// --self-test"): a literal cases table whose row LABELS are the battery roster, +// each floored at 1 and registered as the first statement of the loop body; the +// roster's own size pinned; and a verdict flag the dispatch refuses to run +// without. `judge` only: fixtures in, findings out, no tree read. + +let selfTestReachedVerdict = false; + +const SELF_TEST_BATTERIES = Object.freeze({ + 'every public package in the group → GREEN': 1, + 'a group split over several arrays is read as one → GREEN': 1, + 'a public package outside the group → RED (missing)': 1, + 'a config with no fixed group misses every public package → RED': 1, + 'a group name no workspace package has → RED (stale)': 1, + 'an undeclared private package in the group → RED (stale)': 1, + 'a declared private member in the group, privatePackages.version true → GREEN': 1, + 'a declared private member outside the group → RED': 1, + 'a declared private member while privatePackages.version is not true → RED': 1, + 'a declared member whose manifest is public → RED': 1, + 'a declared member absent from the tree is stale only if the group names it': 1, + 'every shipped PRIVATE_FIXED_MEMBERS row carries its reason': 1, +}); + +const SELF_TEST_BATTERY_FLOOR = 12; + +function selfTest() { + const ws = (publicNames, privateNames = []) => ({ publicNames: [...publicNames].sort(), privateNames: new Set(privateNames) }); + const DECLARED = Object.freeze({ 'ext-x': 'a fixture reason' }); + const verdict = (config, packages, declared = DECLARED) => { + const { missing, stale, declaredProblems } = judge(config, packages, declared); + return { missing, stale, declared: declaredProblems.length }; + }; + const green = { missing: [], stale: [], declared: 0 }; + + const cases = [ + { label: 'every public package in the group → GREEN', got: () => verdict({ fixed: [['@a/one', '@a/two']] }, ws(['@a/one', '@a/two'])), expect: green }, + { + label: 'a group split over several arrays is read as one → GREEN', + got: () => verdict({ fixed: [['@a/one'], ['@a/two']] }, ws(['@a/one', '@a/two'])), + expect: green, + }, + { + label: 'a public package outside the group → RED (missing)', + got: () => verdict({ fixed: [['@a/one']] }, ws(['@a/one', '@a/two'])), + expect: { missing: ['@a/two'], stale: [], declared: 0 }, + }, + { + label: 'a config with no fixed group misses every public package → RED', + got: () => verdict({}, ws(['@a/one', '@a/two'])), + expect: { missing: ['@a/one', '@a/two'], stale: [], declared: 0 }, + }, + { + label: 'a group name no workspace package has → RED (stale)', + got: () => verdict({ fixed: [['@a/one', '@a/gone']] }, ws(['@a/one'])), + expect: { missing: [], stale: ['@a/gone'], declared: 0 }, + }, + { + label: 'an undeclared private package in the group → RED (stale)', + got: () => verdict({ fixed: [['@a/one', 'tool-y']], privatePackages: { version: true } }, ws(['@a/one'], ['tool-y'])), + expect: { missing: [], stale: ['tool-y'], declared: 0 }, + }, + { + label: 'a declared private member in the group, privatePackages.version true → GREEN', + got: () => verdict({ fixed: [['@a/one', 'ext-x']], privatePackages: { version: true } }, ws(['@a/one'], ['ext-x'])), + expect: green, + }, + { + label: 'a declared private member outside the group → RED', + got: () => verdict({ fixed: [['@a/one']], privatePackages: { version: true } }, ws(['@a/one'], ['ext-x'])), + expect: { missing: [], stale: [], declared: 1 }, + }, + { + label: 'a declared private member while privatePackages.version is not true → RED', + got: () => [ + verdict({ fixed: [['@a/one', 'ext-x']] }, ws(['@a/one'], ['ext-x'])), + verdict({ fixed: [['@a/one', 'ext-x']], privatePackages: { version: false } }, ws(['@a/one'], ['ext-x'])), + ], + expect: [{ missing: [], stale: [], declared: 1 }, { missing: [], stale: [], declared: 1 }], + }, + { + label: 'a declared member whose manifest is public → RED', + got: () => verdict({ fixed: [['@a/one', 'ext-x']], privatePackages: { version: true } }, ws(['@a/one', 'ext-x'])), + expect: { missing: [], stale: [], declared: 1 }, + }, + { + label: 'a declared member absent from the tree is stale only if the group names it', + got: () => [verdict({ fixed: [['@a/one']] }, ws(['@a/one'])), verdict({ fixed: [['@a/one', 'ext-x']] }, ws(['@a/one']))], + expect: [green, { missing: [], stale: ['ext-x'], declared: 0 }], + }, + { + label: 'every shipped PRIVATE_FIXED_MEMBERS row carries its reason', + got: () => Object.entries(PRIVATE_FIXED_MEMBERS).filter(([, reason]) => typeof reason !== 'string' || reason.trim() === '').map(([name]) => name), + expect: [], + }, + ]; + + const batterySeen = new Map(); + let failed = 0; + for (const c of cases) { + batterySeen.set(c.label, (batterySeen.get(c.label) ?? 0) + 1); + const got = c.got(); + if (JSON.stringify(got) === JSON.stringify(c.expect)) { + console.log(` ✓ ${c.label}`); + } else { + console.error(` ✗ ${c.label}\n expected ${JSON.stringify(c.expect)}\n got ${JSON.stringify(got)}`); + failed += 1; + } + } + + // The floor: every declared row RAN, evaluated before the verdict so the + // success line can only follow a run whose registered rows equal the roster. + const floorFailure = (message) => { + console.error(`✗ self-test floor: ${message}`); + failed += 1; + }; + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floorFailure( + `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} -- ` + + 'a battery deleted from the roster takes its own floor with it.', + ); + } + const labels = cases.map((c) => c.label); + const duplicated = [...new Set(labels.filter((label, i) => labels.indexOf(label) !== i))]; + if (duplicated.length > 0) { + floorFailure(`the cases table uses ${duplicated.map((l) => JSON.stringify(l)).join(', ')} as a row label more than once.`); + } + for (const [label, count] of batterySeen) { + if (!Object.hasOwn(SELF_TEST_BATTERIES, label)) { + floorFailure(`self-test battery "${label}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES.`); + } + } + for (const label of declared) { + const count = batterySeen.get(label) ?? 0; + if (count < SELF_TEST_BATTERIES[label]) { + floorFailure(`self-test battery "${label}" DID NOT RUN -- ${count} case(s) registered, ${SELF_TEST_BATTERIES[label]} pinned.`); + } + } + + if (failed > 0) { + console.error(`\n✗ check-changeset-fixed self-test: ${failed} failure(s) (cases and floor).`); + process.exit(1); + } + console.log(`\n✓ check-changeset-fixed self-test: ${cases.length} cases pass.`); + selfTestReachedVerdict = true; +} + function main() { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-changeset-fixed self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + return; + } + const packages = listPackageNames(); const { missing, stale, declaredProblems } = judge(readConfig(), packages); diff --git a/scripts/check-ci-filter-parity.mjs b/scripts/check-ci-filter-parity.mjs index 9162ef38272..44654807bc8 100644 --- a/scripts/check-ci-filter-parity.mjs +++ b/scripts/check-ci-filter-parity.mjs @@ -202,14 +202,11 @@ * * ## Wiring * - * Invoked from `.github/workflows/lint.yml` as `node scripts/...` directly, both - * legs, rather than through a `pnpm check:*` alias: see the GATE INVOCATION - * IDIOM note at the top of that file, which states the reasons once. It is NOT - * because root `package.json` is off limits -- that reading of the #9465 fence - * is false, and the note carries the fence's verbatim scope so this docblock - * does not have to: restating it is how the wrong reading spread (#10894). - * The self-test asserts that wiring against the workflow text -- a gate that - * exists and is not scheduled is the same dormant shape from the other side. + * Invoked from `.github/workflows/lint.yml` through its root alias, which runs + * both legs: see the GATE INVOCATION IDIOM note at the top of that file, which + * states the rule once. The self-test asserts that wiring against the workflow + * text and the alias value -- a gate that exists and is not scheduled is the + * same dormant shape from the other side. */ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -1226,8 +1223,22 @@ export async function selfTest() { failures.push(`cannot read .github/workflows/lint.yml to verify wiring: ${err?.code ?? err?.message}`); } if (lint !== null) { - assert(lint.includes(`node ${SELF}\n`), `wiring: lint.yml invokes ${SELF} directly (lint.yml's GATE INVOCATION IDIOM note, not a package.json fence)`); - assert(lint.includes(`node ${SELF} --self-test`), 'wiring: lint.yml runs the --self-test leg too'); + // CI runs the root alias (lint.yml's GATE INVOCATION IDIOM note), so the + // alias is where both legs live: pin the step AND the alias value. + assert( + /^\s*run: pnpm check:ci-filter-parity\s*$/m.test(lint), + `wiring: lint.yml runs ${SELF} through its root alias \`pnpm check:ci-filter-parity\``, + ); + let alias = null; + try { + alias = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')).scripts?.['check:ci-filter-parity'] ?? null; + } catch (err) { + failures.push(`cannot read root package.json to verify wiring: ${err?.code ?? err?.message}`); + } + assert( + alias === `node ${SELF} --self-test && node ${SELF}`, + `wiring: root package.json's check:ci-filter-parity runs the --self-test leg, then the gate -- got ${JSON.stringify(alias)}`, + ); } // The checked-in ci.yml, which the real-tree cases of (9) and (10) read. diff --git a/scripts/check-doc-frontmatter.mjs b/scripts/check-doc-frontmatter.mjs index 0aed3bf35df..4a7e120122d 100644 --- a/scripts/check-doc-frontmatter.mjs +++ b/scripts/check-doc-frontmatter.mjs @@ -227,14 +227,11 @@ * * ## Wiring * - * Invoked from `.github/workflows/lint.yml` as `node scripts/...` directly, both - * legs, rather than through a `pnpm check:*` alias: see the GATE INVOCATION - * IDIOM note at the top of that file, which states the reasons once. It is NOT - * because root `package.json` is off limits -- that reading of the #9465 fence - * is false, and the note carries the fence's verbatim scope so this docblock - * does not have to: restating it is how the wrong reading spread (#10894). - * The self-test asserts that wiring against the workflow text -- a gate that - * exists and is not scheduled is the same dormant shape from the other side. + * Invoked from `.github/workflows/lint.yml` through its root alias, which runs + * both legs: see the GATE INVOCATION IDIOM note at the top of that file, which + * states the rule once. The self-test asserts that wiring against the workflow + * text and the alias value -- a gate that exists and is not scheduled is the + * same dormant shape from the other side. * * Adding the second root needed NO workflow edit: the step already invokes this * script, and `ROOTS` is read from here. `lint.yml` is the repo's busiest file @@ -1553,8 +1550,22 @@ export async function selfTest() { failures.push(`cannot read .github/workflows/lint.yml to verify wiring: ${err.code ?? err.message}`); } if (lint !== null) { - assert(lint.includes(`node ${SELF}\n`), `wiring: lint.yml invokes ${SELF} directly (lint.yml's GATE INVOCATION IDIOM note, not a package.json fence)`); - assert(lint.includes(`node ${SELF} --self-test`), 'wiring: lint.yml runs the --self-test leg too'); + // CI runs the root alias (lint.yml's GATE INVOCATION IDIOM note), so the + // alias is where both legs live: pin the step AND the alias value. + assert( + /^\s*run: pnpm check:doc-frontmatter\s*$/m.test(lint), + `wiring: lint.yml runs ${SELF} through its root alias \`pnpm check:doc-frontmatter\``, + ); + let alias = null; + try { + alias = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')).scripts?.['check:doc-frontmatter'] ?? null; + } catch (err) { + failures.push(`cannot read root package.json to verify wiring: ${err?.code ?? err?.message}`); + } + assert( + alias === `node ${SELF} --self-test && node ${SELF}`, + `wiring: root package.json's check:doc-frontmatter runs the --self-test leg, then the gate -- got ${JSON.stringify(alias)}`, + ); } // ── (11) an unmet prerequisite REFUSES, it does not blame the battery ──── diff --git a/scripts/check-docs-nav-label.mjs b/scripts/check-docs-nav-label.mjs index 17b91df6773..99979a4bc3c 100644 --- a/scripts/check-docs-nav-label.mjs +++ b/scripts/check-docs-nav-label.mjs @@ -736,8 +736,22 @@ export async function selfTest() { failures.push(`cannot read .github/workflows/lint.yml to verify wiring: ${err.code ?? err.message}`); } if (lint !== null) { - assert(lint.includes(`node ${SELF}\n`), `wiring: lint.yml invokes ${SELF} directly (lint.yml's GATE INVOCATION IDIOM note)`); - assert(lint.includes(`node ${SELF} --self-test`), 'wiring: lint.yml runs the --self-test leg too'); + // CI runs the root alias (lint.yml's GATE INVOCATION IDIOM note), so the + // alias is where both legs live: pin the step AND the alias value. + assert( + /^\s*run: pnpm check:docs-nav-label\s*$/m.test(lint), + `wiring: lint.yml runs ${SELF} through its root alias \`pnpm check:docs-nav-label\``, + ); + let alias = null; + try { + alias = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')).scripts?.['check:docs-nav-label'] ?? null; + } catch (err) { + failures.push(`cannot read root package.json to verify wiring: ${err?.code ?? err?.message}`); + } + assert( + alias === `node ${SELF} --self-test && node ${SELF}`, + `wiring: root package.json's check:docs-nav-label runs the --self-test leg, then the gate -- got ${JSON.stringify(alias)}`, + ); } // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── diff --git a/scripts/check-scripts-symbol-anchors.mjs b/scripts/check-scripts-symbol-anchors.mjs index ed49c51adff..350ccd0cb7c 100644 --- a/scripts/check-scripts-symbol-anchors.mjs +++ b/scripts/check-scripts-symbol-anchors.mjs @@ -473,8 +473,14 @@ export function selfTest() { // 6. The gate is wired to run. A gate nothing invokes is this repo's most // carded defect class, and renaming a step silently detaches it. const workflow = readFileSync('.github/workflows/lint.yml', 'utf8'); - check(workflow.includes('node scripts/check-scripts-symbol-anchors.mjs'), 'lint.yml must invoke this gate'); - check(workflow.includes('node scripts/check-scripts-symbol-anchors.mjs --self-test'), 'lint.yml must invoke this gate\'s --self-test'); + // CI runs the root alias (lint.yml's GATE INVOCATION IDIOM note), so pin + // the step AND the alias: this self-test, then the sweep. + check(/^\s*run: pnpm check:scripts-symbol-anchors\s*$/m.test(workflow), 'lint.yml must run this gate through `pnpm check:scripts-symbol-anchors`'); + const alias = JSON.parse(readFileSync('package.json', 'utf8')).scripts?.['check:scripts-symbol-anchors']; + check( + alias === 'node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs', + `root package.json's check:scripts-symbol-anchors must run this gate's --self-test, then the sweep -- got ${JSON.stringify(alias)}`, + ); // 7. The census declaration is intact, and its three instruments stay // ordered — the ordering IS the reading (raw counts code, comment prose diff --git a/scripts/check-spec-docblock-symbol-anchors.mjs b/scripts/check-spec-docblock-symbol-anchors.mjs index 2d2d39e123e..4369001d570 100644 --- a/scripts/check-spec-docblock-symbol-anchors.mjs +++ b/scripts/check-spec-docblock-symbol-anchors.mjs @@ -1271,8 +1271,14 @@ export function selfTest() { // 6. The gate is wired to run. A gate nothing invokes is this repo's most // carded defect class, and renaming a step silently detaches it. const workflow = readFileSync('.github/workflows/lint.yml', 'utf8'); - check(workflow.includes('node scripts/check-spec-docblock-symbol-anchors.mjs'), 'lint.yml must invoke this gate'); - check(workflow.includes('node scripts/check-spec-docblock-symbol-anchors.mjs --self-test'), 'lint.yml must invoke this gate\'s --self-test'); + // CI runs the root alias (lint.yml's GATE INVOCATION IDIOM note), so pin + // the step AND the alias: this self-test, then the sweep. + check(/^\s*run: pnpm check:spec-docblock-symbol-anchors\s*$/m.test(workflow), 'lint.yml must run this gate through `pnpm check:spec-docblock-symbol-anchors`'); + const alias = JSON.parse(readFileSync('package.json', 'utf8')).scripts?.['check:spec-docblock-symbol-anchors']; + check( + alias === 'node scripts/check-spec-docblock-symbol-anchors.mjs --self-test && node scripts/check-spec-docblock-symbol-anchors.mjs', + `root package.json's check:spec-docblock-symbol-anchors must run this gate's --self-test, then the sweep -- got ${JSON.stringify(alias)}`, + ); // 7. The census declaration is intact, and its three instruments stay // ordered — the ordering IS the reading (raw counts code, comment prose