From d1699221f6efa401b942dc9ac043ae69f5193ab5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:01:30 +0000 Subject: [PATCH 1/3] feat(pm): name the type-check lanes dispatch-gates cannot measure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch-gates --commands` emitted `check:type-check-coverage` and `check:type-check-debt` for a TypeScript-touching path and nothing else carrying the word, so a reader who grepped the output for `typecheck` found something and could conclude the surface was covered. Those two ratchet a ledger; the required `TypeScript Type Check` context goes red on per-package `tsc` programs that sat in no bucket at all. Add a type-check lane block, derived from the same parsed workflow entries the always-runs tail and the path-scheduled job block read: every pull-request-workflow step whose `run:` invokes a type-check program, recognised on argv tokens rather than substrings. It renders on every plain run above the tail, prints its rows as `⊘ NOT MEASURED` on the `--commands`/`--json` stderr accounting, carries a `typeCheckLanes` key in `--json`, and is named unconditionally in `outsideBlockNames` so an empty walk cannot hide it. An empty walk renders loud instead. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 206 +++++++++++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 3 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 1c17c94d0d..f6f03db0ce 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2861,6 +2861,88 @@ export function jobFilteredSteps(entries, paths) { return { rows, counts }; } +/** + * Does this `run:` line invoke a TypeScript type-check PROGRAM? + * + * Two spellings, both read as ARGV TOKENS and never as substrings, because the + * substring reading over-matches on this very tree: an `echo` about a + * `tsc-built package` and the lane aggregator's own `console.log` about a + * `type-check lane` each carry the word and neither runs anything. + * + * - `tsc` as a token together with `--noEmit`, `-p` or `--project`; + * - `run` followed by `typecheck` or `type-check` — the task or script name, + * whoever runs it (`turbo run typecheck`, `pnpm --filter X run typecheck`). + * + * ⛔ `check:type-check-coverage` and `check:type-check-debt` match NEITHER: they + * are gate families the matched block already names, and this predicate exists + * because a reader mistook one of them for a lane. + */ +export function isTypeCheckInvocation(command) { + if (typeof command !== 'string') return false; + const tokens = command.split(/\s+/).filter((t) => t !== '').map((t) => t.replace(/^['"]+|['"]+$/g, '')); + if (tokens.includes('tsc') && tokens.some((t) => ['--noEmit', '-p', '--project'].includes(t) || t.startsWith('--project='))) return true; + return tokens.some((t, i) => t === 'run' && ['typecheck', 'type-check'].includes(tokens[i + 1])); +} + +/** + * ⭐ The TYPE-CHECK LANES CI runs — every step whose `run:` invokes a + * TypeScript type-check program, read from the SAME workflow entries the two + * blocks above read, so no block can describe a different revision of a + * workflow than the families printed beside it. + * + * ## The measured failure (#19172) + * + * A dev derived this tool's families for a PR, ran all 82 green, and shipped a + * red on the required `TypeScript Type Check` context: `packages/spec`'s own + * `typecheck` exited 2 on two TS7016 errors one added import line introduced. + * The lanes behind it are CI JOBS running `tsc --noEmit` and `turbo run + * typecheck`, and they were in NO bucket this tool had. + * + * ⭐ And the absence did not read as one. The derivation DOES emit + * `check:type-check-coverage` and `check:type-check-debt` for a + * TypeScript-touching path — measured on that PR's paths, 70 commands of which + * exactly 2 match a `typecheck` grep, both of them those gates. Neither can + * fail the way that PR failed: they ratchet a LEDGER, and what went red is a + * per-package tsc program. So a reader greps for the one word they would grep + * for, FINDS something, and concludes the surface is accounted for — worse + * than a silence, which is noticeable. + * + * Claimed for a row: this step's `run:` invokes a type-check program, read off + * the argv. ⛔ NOT claimed: anything about the step's INTENT — `alwaysRunLines` + * refuses that classification for its own rows and the reason carries + * unchanged. ⛔ NOT runnable and ⛔ never in `--commands`: every row is CI's + * own shell over CI's whole-workspace filters. + * + * A job or step carrying an `if:` is KEPT and MARKED, never excluded: the two + * blocks above drop a conditional because each claims CI definitely runs the + * step, and this one claims only that the lane exists — a lane a reader cannot + * see because it MIGHT be skipped is the absence this block was filed on. + */ +export function typeCheckLaneSteps(entries) { + const rows = []; + const counts = { prWorkflows: 0, nonPullRequestWorkflows: 0, conditional: 0 }; + for (const { file, text } of entries) { + if (!declaresPullRequestTrigger(text)) { + counts.nonPullRequestWorkflows += 1; + continue; + } + counts.prWorkflows += 1; + for (const job of extractJobBlocks(text)) { + for (const step of extractStepBlocks(job.text)) { + const commands = runCommandTexts(step.text) + .flatMap((c) => c.split('\n')) + .map((l) => l.trim()) + .filter((l) => isTypeCheckInvocation(l)); + if (commands.length === 0) continue; + const conditional = Boolean(job.if) || Boolean(step.if); + if (conditional) counts.conditional += 1; + rows.push({ workflow: file, job: job.name, step: step.name, commands, conditional }); + } + } + } + return { rows, counts }; +} + /** * A workflow's OWN declaration that it deliberately has no check family to * discover — a whole-line comment anywhere in the workflow text: @@ -11882,6 +11964,46 @@ export function jobFilteredStepLines(rows, counts) { return lines; } +/** + * The type-check lanes, rendered — printed on EVERY run, like the unreachable + * listing and the two step blocks around it and for the same reason: it is not + * about the card's paths, and the family list provably does not cover it + * (#19172). Rows carry the JOB NAME, which is what CI, branch protection and a + * red check call it — this reader has just been handed a red context name. + * + * ⭐ Absence renders LOUD instead of vanishing: a tree whose pull-request + * workflows yield no lane is a recogniser that has rotted, not a farm with + * nothing left to disclose — the refusal `alwaysRunLines` makes at zero. + */ +export function typeCheckLaneLines(rows, counts) { + const { prWorkflows = 0 } = counts ?? {}; + if (rows.length === 0) { + return [ + 'Type-check lanes — ⊘ NOT MEASURED, and THE SOURCE OF TRUTH CAME BACK EMPTY.', + ` ${prWorkflows} pull-request workflow(s) were read and not one step in them invokes a TypeScript type-check program.`, + ' ⛔ Read that as a BROKEN READ, never as a tree without type checking: this block names what CI runs, so a reading of zero', + ' is a statement about this walk. It is printed rather than dropped because a missing block looks exactly like a covered surface.', + ]; + } + const lines = [ + `Type-check lanes — ${rows.length} CI step(s) run a TypeScript type-check PROGRAM and ⊘ NOT ONE of them is measured by anything above.`, + ' ⛔ NOT the `check:type-check-coverage` / `check:type-check-debt` families the matched block may carry: those ratchet a LEDGER and', + ' a lane goes red on a per-package `tsc` program instead. Finding those two in a grep for `typecheck` is the false reassurance', + ' this block exists to break — an absence a reader could notice would have cost less. A row marked conditional MAY be skipped.', + ' ⛔ NOT runnable as spelled: every row is CI\'s own shell over CI\'s whole-workspace filters, so it sits OUTSIDE the runnable total', + ' and running every command on stdout does ⛔ NOT cover it.', + ' ⇒ What a card owes instead: `pnpm --filter run typecheck` for every package whose TypeScript this diff changes what a program', + ' can SEE — one added import or one new root-level declaration is enough, and that package need not be one your paths matched.', + ]; + for (const row of rows) { + lines.push(` - [${row.workflow} · ${row.job}] ${row.step}${row.conditional ? ' (conditional — CI may skip it)' : ''}`); + for (const command of row.commands.slice(0, ALWAYS_RUN_COMMAND_CAP)) lines.push(` ${command}`); + const elided = row.commands.length - ALWAYS_RUN_COMMAND_CAP; + if (elided > 0) lines.push(` … ${elided} more line(s) — read the step in ${row.workflow}`); + } + return lines; +} + /** * The whole-tree channel, rendered (#14189) — its own heading, identical on * every card, printed ABOVE the reconciliation because its commands are inside @@ -13419,6 +13541,11 @@ export function outsideBlockNames({ // that renders it, like the three above, so the name cannot outlive the // heading. ...(jobFilteredJobs > 0 ? [`the ${jobFilteredJobs} path-scheduled CI job(s)`] : []), + // UNCONDITIONAL, like the unreachable listing and the tail below it and for + // the same reason: its block prints on every run, at zero rows as loudly as + // at four (#19172). ⛔ So it carries no count — a name sized off a row array + // goes missing on exactly the run whose walk came back empty. + 'the type-check lanes', 'the always-runs tail', ]; } @@ -14387,7 +14514,7 @@ function notMeasuredEvidenceTerm(recon) { * That distinction is the card's own subject matter: what is left out of a list * must be visible in the list. */ -export function derivationJson({ paths, size = null, matchedRows, kindGroups, pending, counts, identity, alwaysRunsRows = [], widePopulationRows = [], rosters = [], jobFiltered = { rows: [], counts: {} } }) { +export function derivationJson({ paths, size = null, matchedRows, kindGroups, pending, counts, identity, alwaysRunsRows = [], widePopulationRows = [], rosters = [], jobFiltered = { rows: [], counts: {} }, typeCheckLanes = { rows: [], counts: {} } }) { const commands = commandsFor({ matchedRows, kindGroups, alwaysRunsRows }); const { otherCommands, ...spelling } = spellingSplit(commands); return { @@ -14453,6 +14580,12 @@ export function derivationJson({ paths, size = null, matchedRows, kindGroups, pe // for) and a consumer that had to recount it could name a set the rows do // not contain. jobFilteredSteps: { jobs: jobFiltered.rows, counts: jobFiltered.counts }, + // IN this document and ⛔ NOT in `commands` (#19172), on the disposition of + // the key above it: these are CI's own type-check programs, not families, + // and the two family names that DO carry the word ratchet a ledger. + // `counts` travels beside the rows so a consumer reading an empty `lanes` + // can tell an empty WALK from a tree with no lane in it. + typeCheckLanes: { lanes: typeCheckLanes.rows, counts: typeCheckLanes.counts }, counts, }; } @@ -14476,13 +14609,13 @@ export function derivationJson({ paths, size = null, matchedRows, kindGroups, pe * and the declared WIDE population was not mentioned in it at all. It reads * `outsideBlockNames` now, with the counts this function already holds (#16795). */ -function machineReadableOutput(mode, { paths, size = null, matchedRows, kindGroups, pending, counts, alwaysRunsRows = [], widePopulationRows = [], rosters = [], jobFiltered = { rows: [], counts: {} } }) { +function machineReadableOutput(mode, { paths, size = null, matchedRows, kindGroups, pending, counts, alwaysRunsRows = [], widePopulationRows = [], rosters = [], jobFiltered = { rows: [], counts: {} }, typeCheckLanes = { rows: [], counts: {} } }) { const identity = repoIdentity(); const commands = commandsFor({ matchedRows, kindGroups, alwaysRunsRows }); const split = spellingSplit(commands); if (mode === 'json') { - console.log(JSON.stringify(derivationJson({ paths, size, matchedRows, kindGroups, pending, counts, identity, alwaysRunsRows, widePopulationRows, rosters, jobFiltered }), null, 2)); + console.log(JSON.stringify(derivationJson({ paths, size, matchedRows, kindGroups, pending, counts, identity, alwaysRunsRows, widePopulationRows, rosters, jobFiltered, typeCheckLanes }), null, 2)); } else { for (const command of commands) console.log(command); } @@ -14577,6 +14710,26 @@ function machineReadableOutput(mode, { paths, size = null, matchedRows, kindGrou } console.error(' ⇒ Run without --commands/--json to see each step printed as CI spells it.'); } + // ⭐ The SEVENTH thing stdout deliberately omits (#19172), and the one a + // reader is likeliest to believe is already covered: two families in the list + // above carry the very word they would grep for, and neither is a lane. It is + // stated at BOTH zero and non-zero — an omitted heading reads as a clearance. + if (typeCheckLanes.rows.length) { + console.error( + ` + ${typeCheckLanes.rows.length} CI step(s) run a TYPE-CHECK PROGRAM and are ${mode === 'json' ? 'under typeCheckLanes, not in commands' : 'NOT above'} —` + + " CI's own shell over CI's whole-workspace filters, so there is no local invocation to hand you.", + ); + for (const row of typeCheckLanes.rows) { + console.error(` ⊘ NOT MEASURED — [${row.workflow} · ${row.job}] ${row.commands[0]}${row.conditional ? ' (conditional)' : ''}`); + } + console.error( + ' ⛔ pnpm check:type-check-coverage and pnpm check:type-check-debt are NOT these, whichever list they are in: they' + + ' ratchet a ledger. What this card owes is `pnpm --filter run typecheck` per package whose TypeScript it touches.', + ); + } else { + console.error(` + ⊘ TYPE-CHECK LANES: the walk over ${typeCheckLanes.counts?.prWorkflows ?? 0} pull-request workflow(s) found NONE — read` + + ' that as a broken read, never as a tree without type checking. Run without --commands/--json for the reading.'); + } // The FOURTH thing stdout deliberately omits (#14880), on stderr for exactly // the reason the three above are: the block is prose, and prose in the stream // a consumer executes is the harvest hazard this mode exists to make @@ -14746,6 +14899,10 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si // printed beside, and the whole point of this block is that it states what // the family list does not cover (#16285). const jobFiltered = jobFilteredSteps(workflowEntries, paths); + // Read from those SAME entries for the reason the line above states: a second + // read could name lanes from a revision the families were never derived + // against, and this block's whole claim is about the family list (#19172). + const typeCheckLanes = typeCheckLaneSteps(workflowEntries); if (mode === 'ran') { // Built from the SAME four expressions the other renderings read, in this @@ -14796,6 +14953,7 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si widePopulationRows, rosters, jobFiltered, + typeCheckLanes, counts: { discovered: byCheck.size, workflows: workflows.length, @@ -15104,6 +15262,14 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si for (const line of jobFilteredOut) console.log(line); } + // The type-check lanes (#19172), printed on EVERY run and directly above the + // tail, because the tail is where these steps otherwise dissolve: unnamed, + // unclassified by contract, one row among thirty-three. The heading is the + // repair — two families in the matched block carry the word a reader greps + // for, and this is the block that says what those two are not. + console.log(''); + for (const line of typeCheckLaneLines(typeCheckLanes.rows, typeCheckLanes.counts)) console.log(line); + // The always-runs tail prints on every run for the same reason and with the // same standing: it is not about the card's paths either, and the family list // above provably does not cover it (#13333). Above the residue rather than @@ -26926,6 +27092,40 @@ function selfTest() { outsideBlockCounts(familyReconciliation({ jobFilteredRows: [{}, {}] })).jobFilteredJobs === 2, ); + // ── The type-check lanes (#19172): the negatives are the live over-matches a + // SUBSTRING reading produces here, so a matcher that readmits one reds. ───── + t('a `tsc --noEmit` or `-p ` invocation is a lane', + isTypeCheckInvocation('pnpm --filter @objectstack/spec exec tsc --noEmit') && isTypeCheckInvocation('npx tsc -p tsconfig.test.json')); + t('a `run typecheck` task is a lane whoever runs it', + isTypeCheckInvocation("pnpm exec turbo run typecheck --filter='./packages/*'") && isTypeCheckInvocation("pnpm --filter './examples/*' run typecheck")); + t('⛔ the two LEDGER families are NOT lanes — the substitution this block exists to break', + !isTypeCheckInvocation('pnpm check:type-check-coverage') && !isTypeCheckInvocation('pnpm check:type-check-debt')); + t('⛔ nor is prose that merely carries the word, which is both live over-matches', + !isTypeCheckInvocation('echo "::error::Compiled test files found. A tsc-built package is"') + && !isTypeCheckInvocation('console.log(`::error::type-check lane ${id} concluded ${result}.`);')); + const laneWf = tailWf.replace('run: pnpm lint', "run: pnpm exec turbo run typecheck --filter='./packages/*'") + .replace('run: pnpm check:console-pin', 'run: pnpm --filter @objectstack/spec exec tsc --noEmit'); + const laneFix = typeCheckLaneSteps([{ file: 'fixture.yml', text: laneWf }]); + t('a lane in an unconditional job is a row, and one in a CONDITIONAL job is KEPT and marked', + laneFix.rows.some((r) => r.job === 'gates' && !r.conditional) + && laneFix.rows.some((r) => r.job === 'conditional-job' && r.conditional) && laneFix.counts.conditional === 1, + laneFix.rows.map((r) => `${r.job}:${r.conditional}`).join(' · ')); + t('a workflow with no pull_request trigger contributes no lane and is sized rather than dropped', + typeCheckLaneSteps([{ file: 'p.yml', text: 'on:\n push:\njobs:\n t:\n steps:\n - run: pnpm run typecheck' }]).counts.nonPullRequestWorkflows === 1); + const laneLines = typeCheckLaneLines(laneFix.rows, laneFix.counts); + t('the heading sizes the surface and the block prints the narrowed prescription', + laneLines[0].includes('2 CI step(s)') && laneLines.some((l) => l.includes('pnpm --filter run typecheck'))); + t('⭐ an EMPTY walk renders LOUD rather than dropping the block', + typeCheckLaneLines([], { prWorkflows: 7 })[0].includes('CAME BACK EMPTY') + && typeCheckLaneLines([], { prWorkflows: 7 }).some((l) => l.includes('7 pull-request workflow(s)'))); + t('the closing enumeration names the block UNCONDITIONALLY, so an empty walk cannot hide it', + outsideBlockNames({}).includes('the type-check lanes')); + // ⭐ THE POSITIVE CONTROL, live: the lanes behind the required aggregate are found, and no ledger family is among them. + const liveLanes = typeCheckLaneSteps(liveWorkflows); + t('LIVE: the per-package tsc lanes are named, and no ledger family is mistaken for one', + liveLanes.rows.length > 0 && liveLanes.rows.every((r) => r.commands.every((c) => !c.includes('check:type-check'))), + liveLanes.rows.map((r) => `${r.workflow} · ${r.job}`).join(' · ')); + // ── The seam between this tool and its caller (#13462) ──────────────────── // // Unit half first: the split and the footer are pure, so their edge cases are From 872e04bd19c0f5957f671790a260f6a40619e6aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:14:31 +0000 Subject: [PATCH 2/3] fix(pm): repair the self-test pins the lane block moves The lane fixture replaced a literal that only exists inside a block scalar, so its unconditional row never appeared. Anchor it on the step whose `run:` is a compact one-liner instead. The four verbatim pins on the outside-blocks enumeration are doing their job: they spell the whole phrase in print order, so the new unconditional name belongs in each. Updated in place, including the two negatives that assert the names printed at zero rows. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index f6f03db0ce..ae08095016 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -27103,7 +27103,7 @@ function selfTest() { t('⛔ nor is prose that merely carries the word, which is both live over-matches', !isTypeCheckInvocation('echo "::error::Compiled test files found. A tsc-built package is"') && !isTypeCheckInvocation('console.log(`::error::type-check lane ${id} concluded ${result}.`);')); - const laneWf = tailWf.replace('run: pnpm lint', "run: pnpm exec turbo run typecheck --filter='./packages/*'") + const laneWf = tailWf.replace('run: pnpm check:engine-double-contract', "run: pnpm exec turbo run typecheck --filter='./packages/*'") .replace('run: pnpm check:console-pin', 'run: pnpm --filter @objectstack/spec exec tsc --noEmit'); const laneFix = typeCheckLaneSteps([{ file: 'fixture.yml', text: laneWf }]); t('a lane in an unconditional job is a row, and one in a CONDITIONAL job is KEPT and marked', @@ -27290,7 +27290,7 @@ function selfTest() { // output meets the blocks in the order this line promised them. t('and spells them in the order they are PRINTED below, as one phrase', (outsideLine ?? '').includes( 'The 2 artifact-roster famil(ies), the 1 declared WIDE-population famil(ies), the 3 pending-changeset famil(ies),' - + ' the unreachable listing and the always-runs tail below are each OUTSIDE it, each with its own count.', + + ' the unreachable listing, the type-check lanes and the always-runs tail below are each OUTSIDE it, each with its own count.', )); // The THREE counts are the lengths of the arrays that RENDER those blocks, // so the enumeration cannot name a block the run did not print: at zero rows @@ -28367,7 +28367,7 @@ function selfTest() { } t('and spells them in PRINT order, as the one phrase the human lane spells', ranAllBlocks.includes( 'the 2 artifact-roster famil(ies), the 1 declared WIDE-population famil(ies), the 3 pending-changeset famil(ies),' - + ' the unreachable listing, the 4 path-scheduled CI job(s) and the always-runs tail are each outside the derived total', + + ' the unreachable listing, the 4 path-scheduled CI job(s), the type-check lanes and the always-runs tail are each outside the derived total', )); // The NEGATIVE: at zero rows those three blocks are not printed by the run // this sentence points at, so naming them would send a reader to headings @@ -28380,7 +28380,7 @@ function selfTest() { && !ranNoBlocks.toLowerCase().includes('pending-changeset') && !ranNoBlocks.toLowerCase().includes('path-scheduled'), ); - t('...while still naming the two that print unconditionally', ranNoBlocks.includes('the unreachable listing and the always-runs tail')); + t('...while still naming the three that print unconditionally', ranNoBlocks.includes('the unreachable listing, the type-check lanes and the always-runs tail')); // ── Lane 2: the `--commands` / `--json` stderr accounting ─────────────── // @@ -28457,7 +28457,7 @@ function selfTest() { && !commandsNoBlocks.toLowerCase().includes('pending-changeset') && !commandsNoBlocks.toLowerCase().includes('path-scheduled'), ); - t('...while still naming the two that print unconditionally', commandsNoBlocks.includes('the unreachable listing and the always-runs tail')); + t('...while still naming the three that print unconditionally', commandsNoBlocks.includes('the unreachable listing, the type-check lanes and the always-runs tail')); // ⛔ And the stream stays a STREAM: the accounting is stderr-only, so a // consumer redirecting stdout gets commands with no prose in front of them. // That is the property the whole mode exists for, and a disclaimer that From 9cd7a88dd514760c39f8246e3db359d74a3c541d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:52:51 +0000 Subject: [PATCH 3/3] fix(pm): make the type-check lane walk auditable, and correct its header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of record on PR #19636 returned FAIL with five owed changes. - The live control is pinned to the lanes behind the required aggregate BY NAME (`Type Check · source gates` / `· workspace` / `· consumer gates`) instead of a bare row count, so a partial recogniser miss reds `check:pm-dispatch-gates` rather than dipping silently. `Type Check · debt ledger` stays absent on purpose: its only `run:` IS the ledger family. - `counts` now sizes the walk — command-carrying steps and spliced `run:` lines — and both rendered lanes print that denominator, the discipline the two neighbouring blocks already keep. - Line continuations are spliced with `joinLineContinuations` before the token split, as `jobFilteredSteps` does; an unspliced split drops a continued invocation silently. - The header no longer claims the lanes "were in NO bucket this tool had". Measured: all four rows were already rows of the always-runs tail. What was absent is a NAME and any `--commands` disclosure. - The three human-lane enumeration pins are extended to the new name, matching what the `--ran` and `--commands` lanes already carry. Two nits from the record: the stderr row now elides rather than truncating silently, and the lane walk moved below the `--ran` return, which renders no block of it. Paid for inside the +200 budget by cutting the restated "neither of those two is a lane" note down to the header and one line in each rendered lane. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 152 +++++++++++++++++----------------- 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index ae08095016..9c8d18aa52 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2873,9 +2873,10 @@ export function jobFilteredSteps(entries, paths) { * - `run` followed by `typecheck` or `type-check` — the task or script name, * whoever runs it (`turbo run typecheck`, `pnpm --filter X run typecheck`). * - * ⛔ `check:type-check-coverage` and `check:type-check-debt` match NEITHER: they - * are gate families the matched block already names, and this predicate exists - * because a reader mistook one of them for a lane. + * ⛔ The MISSES are silent, every one — `pnpm typecheck`, `pnpm -r typecheck` + * and `node --run typecheck` carry no `run` token; `tsc --build`, `tsc -b`, + * `vue-tsc` and `tsgo` are not this vocabulary. So the producer SIZES its walk, + * and the live control pins the required aggregate's lanes BY NAME. */ export function isTypeCheckInvocation(command) { if (typeof command !== 'string') return false; @@ -2895,23 +2896,22 @@ export function isTypeCheckInvocation(command) { * A dev derived this tool's families for a PR, ran all 82 green, and shipped a * red on the required `TypeScript Type Check` context: `packages/spec`'s own * `typecheck` exited 2 on two TS7016 errors one added import line introduced. - * The lanes behind it are CI JOBS running `tsc --noEmit` and `turbo run - * typecheck`, and they were in NO bucket this tool had. - * - * ⭐ And the absence did not read as one. The derivation DOES emit - * `check:type-check-coverage` and `check:type-check-debt` for a - * TypeScript-touching path — measured on that PR's paths, 70 commands of which - * exactly 2 match a `typecheck` grep, both of them those gates. Neither can - * fail the way that PR failed: they ratchet a LEDGER, and what went red is a - * per-package tsc program. So a reader greps for the one word they would grep - * for, FINDS something, and concludes the surface is accounted for — worse - * than a silence, which is noticeable. + * + * ⛔ What was missing was NOT the steps. Measured: all four rows this walk + * returns were already rows of the always-runs tail — same workflow, job, step + * and command, 4 of its 33. Missing was a NAME for them and any disclosure at + * all on `--commands`, where a dispatch order is built. ⭐ And that absence did + * not read as one: the derivation DOES emit `check:type-check-coverage` and + * `check:type-check-debt` for a TypeScript-touching path — on that PR's paths, + * 2 of 70 commands matched a `typecheck` grep, both of them those LEDGER gates. + * So a reader greps the one word they would grep, finds something, and stops. * * Claimed for a row: this step's `run:` invokes a type-check program, read off - * the argv. ⛔ NOT claimed: anything about the step's INTENT — `alwaysRunLines` - * refuses that classification for its own rows and the reason carries - * unchanged. ⛔ NOT runnable and ⛔ never in `--commands`: every row is CI's - * own shell over CI's whole-workspace filters. + * the argv of the SPLICED command text (`joinLineContinuations`, the reading + * `jobFilteredSteps` takes — an unspliced split drops a continued invocation + * silently). ⛔ NOT claimed: the step's INTENT — `alwaysRunLines` refuses that + * classification and the reason carries unchanged. ⛔ NOT runnable and ⛔ never + * in `--commands`: every row is CI's own shell over its whole-workspace filters. * * A job or step carrying an `if:` is KEPT and MARKED, never excluded: the two * blocks above drop a conditional because each claims CI definitely runs the @@ -2920,7 +2920,7 @@ export function isTypeCheckInvocation(command) { */ export function typeCheckLaneSteps(entries) { const rows = []; - const counts = { prWorkflows: 0, nonPullRequestWorkflows: 0, conditional: 0 }; + const counts = { prWorkflows: 0, nonPullRequestWorkflows: 0, steps: 0, runLines: 0, conditional: 0 }; for (const { file, text } of entries) { if (!declaresPullRequestTrigger(text)) { counts.nonPullRequestWorkflows += 1; @@ -2929,10 +2929,14 @@ export function typeCheckLaneSteps(entries) { counts.prWorkflows += 1; for (const job of extractJobBlocks(text)) { for (const step of extractStepBlocks(job.text)) { - const commands = runCommandTexts(step.text) - .flatMap((c) => c.split('\n')) + const lines = runCommandTexts(step.text) + .flatMap((c) => joinLineContinuations(c).split('\n')) .map((l) => l.trim()) - .filter((l) => isTypeCheckInvocation(l)); + .filter((l) => l !== ''); + if (lines.length === 0) continue; + counts.steps += 1; + counts.runLines += lines.length; + const commands = lines.filter((l) => isTypeCheckInvocation(l)); if (commands.length === 0) continue; const conditional = Boolean(job.if) || Boolean(step.if); if (conditional) counts.conditional += 1; @@ -11965,35 +11969,32 @@ export function jobFilteredStepLines(rows, counts) { } /** - * The type-check lanes, rendered — printed on EVERY run, like the unreachable - * listing and the two step blocks around it and for the same reason: it is not - * about the card's paths, and the family list provably does not cover it - * (#19172). Rows carry the JOB NAME, which is what CI, branch protection and a - * red check call it — this reader has just been handed a red context name. - * - * ⭐ Absence renders LOUD instead of vanishing: a tree whose pull-request - * workflows yield no lane is a recogniser that has rotted, not a farm with - * nothing left to disclose — the refusal `alwaysRunLines` makes at zero. + * The type-check lanes, rendered — printed on EVERY run, like the two step + * blocks around it and for the same reason: it is not about the card's paths, + * and the family list provably does not cover it (#19172). Rows carry the JOB + * NAME, which is what CI and a red check call it. ⭐ Absence renders LOUD + * instead of vanishing — a tree whose pull-request workflows yield no lane is a + * recogniser that has rotted, not a farm with nothing left to disclose. */ export function typeCheckLaneLines(rows, counts) { - const { prWorkflows = 0 } = counts ?? {}; + const { prWorkflows = 0, steps = 0, runLines = 0 } = counts ?? {}; + const walked = `${steps} command-carrying step(s) / ${runLines} spliced \`run:\` line(s) across ${prWorkflows} pull-request workflow(s)`; if (rows.length === 0) { return [ 'Type-check lanes — ⊘ NOT MEASURED, and THE SOURCE OF TRUTH CAME BACK EMPTY.', - ` ${prWorkflows} pull-request workflow(s) were read and not one step in them invokes a TypeScript type-check program.`, + ` Walked ${walked}, and not one line in them invokes a TypeScript type-check program.`, ' ⛔ Read that as a BROKEN READ, never as a tree without type checking: this block names what CI runs, so a reading of zero', ' is a statement about this walk. It is printed rather than dropped because a missing block looks exactly like a covered surface.', ]; } const lines = [ `Type-check lanes — ${rows.length} CI step(s) run a TypeScript type-check PROGRAM and ⊘ NOT ONE of them is measured by anything above.`, - ' ⛔ NOT the `check:type-check-coverage` / `check:type-check-debt` families the matched block may carry: those ratchet a LEDGER and', - ' a lane goes red on a per-package `tsc` program instead. Finding those two in a grep for `typecheck` is the false reassurance', - ' this block exists to break — an absence a reader could notice would have cost less. A row marked conditional MAY be skipped.', - ' ⛔ NOT runnable as spelled: every row is CI\'s own shell over CI\'s whole-workspace filters, so it sits OUTSIDE the runnable total', - ' and running every command on stdout does ⛔ NOT cover it.', - ' ⇒ What a card owes instead: `pnpm --filter run typecheck` for every package whose TypeScript this diff changes what a program', - ' can SEE — one added import or one new root-level declaration is enough, and that package need not be one your paths matched.', + ` Walked ${walked} to find them: the DENOMINATOR, so a recogniser that stops spelling a lane shows as a dip rather than as silence.`, + ' ⛔ NOT the `check:type-check-coverage` / `check:type-check-debt` families the matched block may carry: those ratchet a LEDGER and a', + ' lane reds on a per-package `tsc` program instead — finding those two in a grep for `typecheck` is the false reassurance this block', + ' exists to break. NOT runnable as spelled either: CI\'s own shell over CI\'s whole-workspace filters, OUTSIDE the runnable total, and', + ' a row marked conditional MAY be skipped. ⇒ What a card owes instead: `pnpm --filter run typecheck` for every package whose', + ' TypeScript this diff changes what a program can SEE — one added import or one new root-level declaration is enough.', ]; for (const row of rows) { lines.push(` - [${row.workflow} · ${row.job}] ${row.step}${row.conditional ? ' (conditional — CI may skip it)' : ''}`); @@ -13541,10 +13542,9 @@ export function outsideBlockNames({ // that renders it, like the three above, so the name cannot outlive the // heading. ...(jobFilteredJobs > 0 ? [`the ${jobFilteredJobs} path-scheduled CI job(s)`] : []), - // UNCONDITIONAL, like the unreachable listing and the tail below it and for - // the same reason: its block prints on every run, at zero rows as loudly as - // at four (#19172). ⛔ So it carries no count — a name sized off a row array - // goes missing on exactly the run whose walk came back empty. + // UNCONDITIONAL, like the unreachable listing and the tail below it: its + // block prints on every run, at zero rows as loudly as at four (#19172). ⛔ + // So no count — a name sized off a row array goes missing on the empty walk. 'the type-check lanes', 'the always-runs tail', ]; @@ -14581,10 +14581,8 @@ export function derivationJson({ paths, size = null, matchedRows, kindGroups, pe // not contain. jobFilteredSteps: { jobs: jobFiltered.rows, counts: jobFiltered.counts }, // IN this document and ⛔ NOT in `commands` (#19172), on the disposition of - // the key above it: these are CI's own type-check programs, not families, - // and the two family names that DO carry the word ratchet a ledger. - // `counts` travels beside the rows so a consumer reading an empty `lanes` - // can tell an empty WALK from a tree with no lane in it. + // the key above it: these are CI's own type-check programs, not families. + // `counts` is the walk's DENOMINATOR — an empty `lanes` is not a bare tree. typeCheckLanes: { lanes: typeCheckLanes.rows, counts: typeCheckLanes.counts }, counts, }; @@ -14710,25 +14708,23 @@ function machineReadableOutput(mode, { paths, size = null, matchedRows, kindGrou } console.error(' ⇒ Run without --commands/--json to see each step printed as CI spells it.'); } - // ⭐ The SEVENTH thing stdout deliberately omits (#19172), and the one a - // reader is likeliest to believe is already covered: two families in the list - // above carry the very word they would grep for, and neither is a lane. It is - // stated at BOTH zero and non-zero — an omitted heading reads as a clearance. + // ⭐ The SEVENTH thing stdout deliberately omits (#19172) — and the lane this + // card was filed on, because `--commands` disclosed it in no form at all. It + // is stated at BOTH zero and non-zero: an omitted heading reads as a clearance. if (typeCheckLanes.rows.length) { console.error( ` + ${typeCheckLanes.rows.length} CI step(s) run a TYPE-CHECK PROGRAM and are ${mode === 'json' ? 'under typeCheckLanes, not in commands' : 'NOT above'} —` + - " CI's own shell over CI's whole-workspace filters, so there is no local invocation to hand you.", + " CI's own shell over CI's whole-workspace filters, so there is no local invocation to hand you." + + ` Walked ${typeCheckLanes.counts?.steps ?? 0} step(s) / ${typeCheckLanes.counts?.runLines ?? 0} run: line(s) to find them.`, ); for (const row of typeCheckLanes.rows) { - console.error(` ⊘ NOT MEASURED — [${row.workflow} · ${row.job}] ${row.commands[0]}${row.conditional ? ' (conditional)' : ''}`); + const more = row.commands.length > 1 ? ` (+${row.commands.length - 1} more lane line(s) in this step)` : ''; + console.error(` ⊘ NOT MEASURED — [${row.workflow} · ${row.job}] ${row.commands[0]}${more}${row.conditional ? ' (conditional)' : ''}`); } - console.error( - ' ⛔ pnpm check:type-check-coverage and pnpm check:type-check-debt are NOT these, whichever list they are in: they' + - ' ratchet a ledger. What this card owes is `pnpm --filter run typecheck` per package whose TypeScript it touches.', - ); + console.error(' ⛔ pnpm check:type-check-coverage and pnpm check:type-check-debt are NOT these, whichever list they are in:' + + ' they ratchet a ledger. What this card owes is `pnpm --filter run typecheck` per package whose TypeScript it touches.'); } else { - console.error(` + ⊘ TYPE-CHECK LANES: the walk over ${typeCheckLanes.counts?.prWorkflows ?? 0} pull-request workflow(s) found NONE — read` - + ' that as a broken read, never as a tree without type checking. Run without --commands/--json for the reading.'); + console.error(` + ⊘ TYPE-CHECK LANES: ${typeCheckLanes.counts?.steps ?? 0} step(s) walked across ${typeCheckLanes.counts?.prWorkflows ?? 0} pull-request workflow(s), NONE found — read that as a broken read, never as a tree without type checking.`); } // The FOURTH thing stdout deliberately omits (#14880), on stderr for exactly // the reason the three above are: the block is prose, and prose in the stream @@ -14899,10 +14895,6 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si // printed beside, and the whole point of this block is that it states what // the family list does not cover (#16285). const jobFiltered = jobFilteredSteps(workflowEntries, paths); - // Read from those SAME entries for the reason the line above states: a second - // read could name lanes from a revision the families were never derived - // against, and this block's whole claim is about the family list (#19172). - const typeCheckLanes = typeCheckLaneSteps(workflowEntries); if (mode === 'ran') { // Built from the SAME four expressions the other renderings read, in this @@ -14942,6 +14934,10 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si return recon.ok ? 0 : 1; } + // The SAME entries, for the reason the `jobFiltered` line states — and BELOW + // the `--ran` return, which renders no block of it (#19172). + const typeCheckLanes = typeCheckLaneSteps(workflowEntries); + if (mode !== 'human') { machineReadableOutput(mode, { paths, @@ -15262,11 +15258,10 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si for (const line of jobFilteredOut) console.log(line); } - // The type-check lanes (#19172), printed on EVERY run and directly above the - // tail, because the tail is where these steps otherwise dissolve: unnamed, - // unclassified by contract, one row among thirty-three. The heading is the - // repair — two families in the matched block carry the word a reader greps - // for, and this is the block that says what those two are not. + // The type-check lanes (#19172), directly above the tail because the tail is + // where these steps otherwise dissolve: one row among thirty-three, unnamed + // and unclassified by contract. The heading IS the repair — the rows were + // never missing, the name was. console.log(''); for (const line of typeCheckLaneLines(typeCheckLanes.rows, typeCheckLanes.counts)) console.log(line); @@ -27092,8 +27087,7 @@ function selfTest() { outsideBlockCounts(familyReconciliation({ jobFilteredRows: [{}, {}] })).jobFilteredJobs === 2, ); - // ── The type-check lanes (#19172): the negatives are the live over-matches a - // SUBSTRING reading produces here, so a matcher that readmits one reds. ───── + // ── Type-check lanes (#19172): the negatives are the live over-matches a SUBSTRING reading produces here ── t('a `tsc --noEmit` or `-p ` invocation is a lane', isTypeCheckInvocation('pnpm --filter @objectstack/spec exec tsc --noEmit') && isTypeCheckInvocation('npx tsc -p tsconfig.test.json')); t('a `run typecheck` task is a lane whoever runs it', @@ -27120,11 +27114,15 @@ function selfTest() { && typeCheckLaneLines([], { prWorkflows: 7 }).some((l) => l.includes('7 pull-request workflow(s)'))); t('the closing enumeration names the block UNCONDITIONALLY, so an empty walk cannot hide it', outsideBlockNames({}).includes('the type-check lanes')); - // ⭐ THE POSITIVE CONTROL, live: the lanes behind the required aggregate are found, and no ledger family is among them. + // ⭐ THE POSITIVE CONTROL, live, pinned BY NAME and ⛔ never by row count: a + // count stays green while three of four lanes vanish. `Type Check · debt + // ledger` is deliberately absent — its only `run:` IS the ledger family. const liveLanes = typeCheckLaneSteps(liveWorkflows); - t('LIVE: the per-package tsc lanes are named, and no ledger family is mistaken for one', - liveLanes.rows.length > 0 && liveLanes.rows.every((r) => r.commands.every((c) => !c.includes('check:type-check'))), - liveLanes.rows.map((r) => `${r.workflow} · ${r.job}`).join(' · ')); + const liveLaneJobs = liveLanes.rows.map((r) => r.job); + t('LIVE: every lane behind the required aggregate is found BY NAME, and no ledger family is mistaken for one', + ['Type Check · source gates', 'Type Check · workspace', 'Type Check · consumer gates'].every((j) => liveLaneJobs.includes(j)) + && liveLanes.rows.every((r) => r.commands.every((c) => !c.includes('check:type-check'))), + `${liveLaneJobs.join(' · ')} - walked ${liveLanes.counts.steps} step(s) / ${liveLanes.counts.runLines} run line(s)`); // ── The seam between this tool and its caller (#13462) ──────────────────── // @@ -27280,6 +27278,7 @@ function selfTest() { 'the 1 declared WIDE-population famil(ies)', 'the 3 pending-changeset famil(ies)', 'the unreachable listing', + 'the type-check lanes', 'the always-runs tail', ]) { t(`and it names "${name}" — every block printed below it, not a subset`, namesOutside(outsideLine, [name])); @@ -27304,7 +27303,7 @@ function selfTest() { // pending family the sentence pointed below at a heading that is not there // (#16795). It is conditional on its own count now, like the two above it. t('nor the pending-changeset block, whose heading is absent at zero too', !(noBlocksLine ?? '').toLowerCase().includes('pending-changeset')); - t('...while still naming the two blocks that print unconditionally', namesOutside(noBlocksLine, ['the unreachable listing', 'the always-runs tail'])); + t('...while still naming the three blocks that print unconditionally', namesOutside(noBlocksLine, ['the unreachable listing', 'the type-check lanes', 'the always-runs tail'])); // ...and the CONTROL for that pair: a run with pending families and nothing // else names the third block and neither of the other two, so the case // above cannot be passing because the name went away for good. @@ -27325,6 +27324,7 @@ function selfTest() { 'the 1 declared WIDE-population famil(ies)', 'the 1 pending-changeset famil(ies)', 'the unreachable listing', + 'the type-check lanes', 'the always-runs tail', ]))); // ...and the SHORT-harvest warning is conditional, on the rule the ⛔