diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 1c17c94d0d6..9c8d18aa52d 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2861,6 +2861,92 @@ 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`). + * + * ⛔ 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; + 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. + * + * ⛔ 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 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 + * 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, steps: 0, runLines: 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 lines = runCommandTexts(step.text) + .flatMap((c) => joinLineContinuations(c).split('\n')) + .map((l) => l.trim()) + .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; + 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 +11968,43 @@ export function jobFilteredStepLines(rows, counts) { return lines; } +/** + * 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, 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.', + ` 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.`, + ` 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)' : ''}`); + 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 +13542,10 @@ 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: 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', ]; } @@ -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,10 @@ 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. + // `counts` is the walk's DENOMINATOR — an empty `lanes` is not a bare tree. + typeCheckLanes: { lanes: typeCheckLanes.rows, counts: typeCheckLanes.counts }, counts, }; } @@ -14476,13 +14607,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 +14708,24 @@ 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 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." + + ` Walked ${typeCheckLanes.counts?.steps ?? 0} step(s) / ${typeCheckLanes.counts?.runLines ?? 0} run: line(s) to find them.`, + ); + for (const row of typeCheckLanes.rows) { + 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.'); + } else { + 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 // a consumer executes is the harvest hazard this mode exists to make @@ -14785,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, @@ -14796,6 +14949,7 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si widePopulationRows, rosters, jobFiltered, + typeCheckLanes, counts: { discovered: byCheck.size, workflows: workflows.length, @@ -15104,6 +15258,13 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si for (const line of jobFilteredOut) console.log(line); } + // 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); + // 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 +27087,43 @@ function selfTest() { outsideBlockCounts(familyReconciliation({ jobFilteredRows: [{}, {}] })).jobFilteredJobs === 2, ); + // ── 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', + 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 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', + 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, 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); + 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) ──────────────────── // // Unit half first: the split and the footer are pure, so their edge cases are @@ -27080,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])); @@ -27090,7 +27289,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 @@ -27104,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. @@ -27125,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 ⛔ @@ -28167,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 @@ -28180,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 ─────────────── // @@ -28257,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