diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 58cde83149..f2bf7b2be2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -169,6 +169,30 @@ jobs: - name: PM skill issue-ID lint run: pnpm check:pm-skill-id-lint + # 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, and like that one this + # step is deliberately UNCONDITIONAL: no `if:`, no label read, no paths + # filter. An exemption is what a self-test must not have, or the gap moves. + # + # 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). Reads two files and walks + # packages/; ~0.3s. + - name: PM dispatch-gates self-test + run: pnpm check:pm-dispatch-gates + # Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in # Markdown/MDX are not type-checked or ESLinted, so skills/ and # content/docs/ can drift back to teaching the bare `: Page = {}` literal diff --git a/package.json b/package.json index fe0d9d4b60..5f61e8d8a1 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "check:agent-model-declared": "node scripts/check-agent-model-declared.mjs --self-test && node scripts/check-agent-model-declared.mjs", "check:pm-skill-ratchet": "node scripts/pm/check-skill-line-ratchet.mjs --self-test && node scripts/pm/check-skill-line-ratchet.mjs", "check:pm-skill-id-lint": "node scripts/pm/check-skill-id-lint.mjs --self-test && node scripts/pm/check-skill-id-lint.mjs", + "check:pm-dispatch-gates": "node scripts/pm/check-dispatch-gates.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-merge-approval": "node scripts/check-adr-merge-approval.mjs --self-test && node scripts/check-adr-merge-approval.mjs", diff --git a/scripts/pm/check-dispatch-gates.mjs b/scripts/pm/check-dispatch-gates.mjs new file mode 100644 index 0000000000..30e935183d --- /dev/null +++ b/scripts/pm/check-dispatch-gates.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check:pm-dispatch-gates (#8162) — the CI gate for the dispatch-gates tool. + * + * node scripts/pm/check-dispatch-gates.mjs # runs the tool's --self-test + * + * ⚠️ This header names repo paths UNQUOTED on purpose — see the last section. + * + * ## Why the gate exists + * + * scripts/pm/dispatch-gates.mjs derives the "local gates for this card" line of + * every dispatch prompt. It carried a --self-test from its first commit — 61 + * cases at the time this gate was written, covering workflow extraction, script + * resolution, watch-hint scanning, the runnable-invocation rendering and the + * change-kind derivation — and no CI job ran a single one of them. The + * self-test executed only when a human or an agent typed it, which makes it a + * check whose coverage is a function of who remembered. + * + * The failure that shape produces is quiet: a break in the extraction functions + * lands green, and it surfaces later as a dispatch prompt naming the WRONG gate + * families — output that reads as correct, on the very tool whose purpose is to + * stop gate lists from being memory-shaped. Same family as the changeset gates' + * self-tests, which is why the workflow step, like theirs, is unconditional. + * + * ## Why the self-test ONLY, and not the live derivation + * + * The live derivation answers a QUESTION about a card's file surface: it + * re-reads every workflow file and every check script's source, prints leads, + * and exits 0 on any completed run. There is no verdict in it for CI to hold — + * gating on it would buy a slow read of the whole workflow tree whose exit code + * is 0 by construction. What CI can hold is the half that HAS a verdict, the + * self-test. That self-test is not fixture-only either: it reads the real + * pr-automation.yml and walks the real packages tree in the cases where a + * fixture cannot prove the point, so the derivation's contact with reality is + * covered by this gate too. + * + * ## Why this file exists instead of pointing the script at the tool directly + * + * The obvious spelling is to make check:pm-dispatch-gates run the tool's own + * --self-test, with no file in between. That is the one shape this particular + * tool cannot have. The derivation resolves a check family to its script file + * and then scans THAT FILE's source for the path literals it operates on — its + * watch hints — and dispatch-gates.mjs is a tool whose own tests are made of + * path strings. Measured on the tree at the time of writing: 49 literals + * extracted from its source, of which 2 name inputs it really reads and 43 are + * self-test FIXTURES naming other packages. Wired directly, the derivation + * printed a MATCHED line for this gate on a card touching spec's filter schema, + * matched via a fixture string inside the self-test — a fabricated lead in the + * column the tool's contract reserves for high-signal answers, for most of the + * tree. The tool's own header rejects exactly that ("22 leads is the same as + * none"), so shipping it as the price of gating the tool would have taken more + * from every dispatch prompt than the gate gives back. + * + * A separate gate file is also what the other two pm gates look like + * (check-skill-line-ratchet.mjs, check-skill-id-lint.mjs). Its watch hints are + * the one constant below: this gate is matched for a card that edits the tool, + * and for nothing else — which is the blind spot #8162 is about. + * + * ## Why the paths above are unquoted + * + * Watch-hint extraction reads any quoted-looking span, backticks included, and + * does not skip comments. Written the ordinary way, with each path in backticks, + * this header alone yielded ten hints — packages/spec/src, packages/objectql, + * packages/plugins, packages/drivers, .claude/agents, .changeset among them — + * and reproduced, from the file explaining the pollution, the exact false + * MATCHED leads it exists to avoid (measured, not predicted: the first draft of + * this file did it). So paths are named unquoted here, and the only quoted path + * in this file is the one input this gate genuinely has. + * + * Nothing else belongs in this file. Assertions go in the tool's own self-test, + * beside the code they judge; this is the CI invocation and its reason. + */ + +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import process from 'node:process'; + +const ROOT = new URL('../..', import.meta.url).pathname; + +/** The tool under test, repo-relative — and this gate's only watch hint. */ +const TOOL = 'scripts/pm/dispatch-gates.mjs'; + +const result = spawnSync(process.execPath, [join(ROOT, TOOL), '--self-test'], { stdio: 'inherit' }); + +if (result.error) { + console.error(`✗ check:pm-dispatch-gates: could not run ${TOOL} — ${result.error.message}`); + process.exit(2); +} +if (result.signal) { + console.error(`✗ check:pm-dispatch-gates: ${TOOL} --self-test was killed by ${result.signal}.`); + process.exit(2); +} +process.exit(result.status ?? 2); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 76bf8739af..19d080f616 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -642,7 +642,16 @@ function selfTest() { const resolved = (name) => `pnpm ${name}`; const kindHit = changeKindLines(['packages/objectql/src/engine.test.ts'], resolved); t('a test path emits the convention section', kindHit.length === 3 && kindHit[0].includes('adds or edits a test file')); - t('the section names both convention gates, runnably', kindHit.some((l) => l.includes('pnpm check:query-options-erasure')) && kindHit.some((l) => l.includes('pnpm check:type-check-coverage'))); + // Both halves anchor on the rendered DELIMITERS (`- pnpm x —`), for the + // reason the i18n entry's pins below state at length: a bare `includes` is + // satisfied by every name that merely STARTS WITH the expected one, so a + // prefix-preserving rename is invisible to it — the single rot class the STALE + // branch exists to report. Measured on this entry rather than inherited from + // that one: renaming these gates to `check:query-options-erasure-v2` and + // `check:type-check-coverage-v2` in CHANGE_KIND_GATES left the substring form + // green at 61/61 while the live run printed both as STALE; anchored, the same + // rename fails this case. The two conventions in this file now agree. + t('the section names both convention gates, runnably', kindHit.some((l) => l.includes('- pnpm check:query-options-erasure —')) && kindHit.some((l) => l.includes('- pnpm check:type-check-coverage —'))); t('a non-test path emits nothing', changeKindLines(['scripts/pm/dispatch-gates.mjs'], resolved).length === 0); // i18n change-kind derivation — the pure judgments first, each mirroring one