7272 * `check:pm-governed-prose`, `check:required-contexts` and five others already
7373 * take for `AGENTS.md/**`. After it, a turbo.json edit derives this family.
7474 *
75- * ## What this gate deliberately does NOT judge, stated rather than discovered
76- *
77- * GENERIC task keys -- the ones with no `#`, like `build` or `test:e2e` -- are
78- * out of population. The invariant is the same shape ("a task nothing can run
79- * is inert"), and on the tree this landed against it has a live violation:
80- * `test:e2e` is defined here with `outputs: ["playwright-report/**", ...]` and
81- * is held by ZERO of the 78 workspace packages -- the real Playwright script in
82- * `examples/app-showcase` is spelled `test:smoke`, which turbo.json does not
83- * configure at all. Closing that needs an edit to `turbo.json`, which is not
84- * this card's file surface, and a gate that ships red is worse than no gate.
85- * Filed as #12373; widen this gate's population to generic keys in the same
86- * change that fixes that entry, not before.
75+ * ## GENERIC task keys, and the near miss that reads as a refutation (#12373)
76+ *
77+ * GENERIC keys -- the ones with no `#`, like `build` or `test:smoke` -- are in
78+ * population as of #12373, under the same invariant one level up: a generic key
79+ * no workspace package declares a script for configures a task that can never
80+ * run. Turbo is silent about it in the same way, and the silence is worse here
81+ * because it reaches a human directly: the root `package.json` wraps these keys
82+ * as `turbo run <task>`, so an inert one is a command that exits 0 having run
83+ * nothing, handed around as evidence a suite passed (#4690's family).
84+ *
85+ * That is not hypothetical, it is the entry this limb was written for. Measured
86+ * from git: `test:e2e` entered turbo.json on 2026-05-21 (7972e7b829) when
87+ * `examples/app-crm` declared `"test:e2e": "playwright test"` beside an `e2e/`
88+ * directory and a `playwright.config.ts`. On 2026-05-24, `e737fbce39`
89+ * ("simplify app-crm to minimal metadata smoke-test") deleted that script. The
90+ * turbo entry stayed, and for the three months to 2026-08-26 it configured a
91+ * task no package could run, while `pnpm test:e2e` kept exiting 0.
92+ *
93+ * ⚠️ The ROOT manifest is not a workspace member and does not make a generic key
94+ * live -- and counting it is the mistake that reads as a refutation of the
95+ * finding rather than as a different measurement. `test:e2e` was held by ONE
96+ * file (the root `package.json`) and by ZERO of the 78 members, and only the
97+ * second number decides whether `turbo run test:e2e` matches anything. The
98+ * failure text below says so where it will be read.
99+ *
100+ * A generic key that exists only as a `dependsOn` target is judged the same, on
101+ * purpose. Measured on the tree this limb landed against: the only `dependsOn`
102+ * targets in the whole file are `build` and `^build`, and `build` is declared by
103+ * 72 of the 78 members -- so no such key exists here to exempt. Nor should one
104+ * be exempt if it appears: turbo resolves `dependsOn` against the DEFINITION, so
105+ * a dependency on a generic task no package declares still runs nothing, and the
106+ * exemption would be a hole shaped exactly like the entry above.
87107 *
88108 * ## Refusals, never quiet passes (#4690)
89109 *
@@ -191,20 +211,63 @@ export function editDistanceWithin(a, b, max) {
191211 return prev [ b . length ] <= max ;
192212}
193213
214+ /**
215+ * Every workspace package declaring a script called `task`, sorted.
216+ *
217+ * This is the whole of what a GENERIC key is judged against, and the membership
218+ * it reads is the enumerator's -- so the root manifest, which is not a member,
219+ * cannot make a key look held. See the header for why that distinction is the
220+ * one this limb is most likely to be argued out of.
221+ *
222+ * @param {string } task
223+ * @param {Map<string, Set<string>> } scriptsByPackage
224+ * @returns {string[] }
225+ */
226+ export function holdersOf ( task , scriptsByPackage ) {
227+ const holders = [ ] ;
228+ for ( const [ pkg , scripts ] of scriptsByPackage ) if ( scripts . has ( task ) ) holders . push ( pkg ) ;
229+ return holders . sort ( ) ;
230+ }
231+
194232/**
195233 * The rule, as a pure function over already-parsed inputs, so `--self-test` can
196234 * drive it with the adversarial task tables a clean tree does not contain.
197235 *
236+ * The two populations are counted separately and BOTH are returned: a limb that
237+ * quietly stops matching reports zero findings exactly like a limb that found
238+ * nothing wrong, so `main()` needs each count to assert it read something.
239+ *
198240 * @param {Record<string, unknown> } tasks turbo.json's `tasks` table
199241 * @param {Map<string, Set<string>> } scriptsByPackage package name -> script names
200- * @returns {{ problems: string[], judged: number } }
242+ * @returns {{ problems: string[], judged: number, genericJudged: number } }
201243 */
202244export function verdict ( tasks , scriptsByPackage ) {
203245 const problems = [ ] ;
204246 let judged = 0 ;
247+ let genericJudged = 0 ;
205248 for ( const key of Object . keys ( tasks ) ) {
206249 const split = splitTaskKey ( key ) ;
207- if ( ! split ) continue ;
250+ if ( ! split ) {
251+ genericJudged += 1 ;
252+ if ( holdersOf ( key , scriptsByPackage ) . length === 0 ) {
253+ const declaredAnywhere = new Set ( ) ;
254+ for ( const scripts of scriptsByPackage . values ( ) ) for ( const name of scripts ) declaredAnywhere . add ( name ) ;
255+ const near = nearestNames ( key , declaredAnywhere ) ;
256+ problems . push (
257+ `"${ key } " is a generic task that NO workspace package declares a script for.\n` +
258+ ` Turbo is silent about this too: a run matches nothing and exits 0, so the root\n` +
259+ ` script that wraps it ("turbo run ${ key } ") is a command handed around as evidence\n` +
260+ ` a suite passed while running nothing at all.\n` +
261+ ` ⚠️ The ROOT package.json does NOT count and is the near miss that reads as a\n` +
262+ ` refutation: it is not one of the ${ scriptsByPackage . size } workspace members this gate\n` +
263+ ` enumerates, so a script there leaves the key held by zero of them and just as inert.\n` +
264+ ( near . length ? ` Did you mean: ${ near . join ( ', ' ) } ?\n` : '' ) +
265+ ` Rename the key to the script the workspace declares, add that script to the\n` +
266+ ` package that should run it, or delete the entry.` ,
267+ ) ;
268+ }
269+ continue ;
270+ }
208271 judged += 1 ;
209272 const { pkg, task } = split ;
210273 const scripts = scriptsByPackage . get ( pkg ) ;
@@ -238,7 +301,7 @@ export function verdict(tasks, scriptsByPackage) {
238301 ) ;
239302 }
240303 }
241- return { problems, judged } ;
304+ return { problems, judged, genericJudged } ;
242305}
243306
244307/**
@@ -313,7 +376,19 @@ function main() {
313376 throw err ;
314377 }
315378
316- const { problems, judged } = verdict ( tasks , scriptsByPackage ) ;
379+ const { problems, judged, genericJudged } = verdict ( tasks , scriptsByPackage ) ;
380+
381+ if ( genericJudged === 0 ) {
382+ console . error (
383+ `FAIL: ${ TURBO_CONFIG_FILE } declares no generic (\`#\`-less) task key at all, so half of\n` +
384+ ` this gate's population is empty. That is not a clean tree: the root package.json\n` +
385+ ` wraps \`build\`, \`test\` and \`typecheck\` as \`turbo run <task>\`, and each of those is a\n` +
386+ ` generic key in this table. Zero of them means this gate read the wrong file, or\n` +
387+ ` splitTaskKey stopped classifying a \`#\`-less key as generic — never that a table\n` +
388+ ` with nothing generic in it was found.` ,
389+ ) ;
390+ process . exit ( 1 ) ;
391+ }
317392
318393 if ( judged === 0 ) {
319394 console . error (
@@ -326,13 +401,17 @@ function main() {
326401 }
327402
328403 if ( problems . length ) {
329- console . error ( `FAIL: ${ TURBO_CONFIG_FILE } carries ${ problems . length } inert task override(s).\n` ) ;
404+ console . error (
405+ `FAIL: ${ TURBO_CONFIG_FILE } carries ${ problems . length } inert task entr${ problems . length === 1 ? 'y' : 'ies' } .\n` ,
406+ ) ;
330407 for ( const p of problems ) console . error ( ` - ${ p } \n` ) ;
331408 console . error (
332- 'Why this gate exists: turbo does NOT refuse either of these. A task key naming a\n' +
333- 'package that does not exist, or a task the package has no script for, exits 0 with\n' +
334- 'no diagnostic and configures nothing — so the edit that was meant to change what CI\n' +
335- 'builds, orders or caches reads as landed while doing nothing at all (#12046).\n' +
409+ 'Why this gate exists: turbo does NOT refuse any of these. A task key naming a\n' +
410+ 'package that does not exist, a task the package has no script for, or a generic key\n' +
411+ 'no package declares at all, exits 0 with no diagnostic and configures nothing — so\n' +
412+ 'the edit that was meant to change what CI builds, orders or caches reads as landed\n' +
413+ 'while doing nothing at all (#12046), and a root script wrapping the generic key\n' +
414+ 'reads as a suite that passed (#12373).\n' +
336415 '\n' +
337416 '⛔ `dependsOn` is deliberately NOT checked here: turbo already refuses an\n' +
338417 'unresolvable one, loudly, with exit 1. Re-checking it would be this gate claiming a\n' +
@@ -342,8 +421,10 @@ function main() {
342421 }
343422
344423 console . log (
345- `OK: ${ judged } package-scoped turbo task(s) judged against ${ scriptsByPackage . size } workspace ` +
346- `package(s) — every one names a package that exists and a script it declares.` ,
424+ `OK: ${ judged } package-scoped and ${ genericJudged } generic turbo task(s) judged against ` +
425+ `${ scriptsByPackage . size } workspace package(s) — every package-scoped one names a package ` +
426+ `that exists and a script it declares, and every generic one is declared by at least one ` +
427+ `member.` ,
347428 ) ;
348429}
349430
@@ -390,19 +471,47 @@ export function selfTest() {
390471 t ( 'a length gap wider than the cap is refused before any work' , ! editDistanceWithin ( 'a' , 'abcd' , 2 ) ) ;
391472 t ( 'no near name yields no suggestion' , nearestNames ( '@acme/nothing-like-it' , workspace . keys ( ) ) . length === 0 ) ;
392473
474+ // ── The generic limb (#12373), both directions ──
475+ // The red case is the entry this limb was written for, spelled as it stood on
476+ // 2026-08-26: a key every signal calls fine, held by nobody who could run it.
477+ const inertGeneric = verdict ( { 'test:e2e' : { } } , workspace ) ;
478+ t ( 'a generic task no package declares is a finding' , inertGeneric . problems . length === 1 ) ;
479+ t ( 'the inert generic key is counted in the generic population' , inertGeneric . genericJudged === 1 ) ;
480+ t ( 'the inert generic key is NOT counted as a package task' , inertGeneric . judged === 0 ) ;
481+ // Pinned as text because this sentence is the whole defence of the measurement:
482+ // the root manifest holds `test:e2e` and is not a member, and a reader who
483+ // counts files instead of members reads the finding as already refuted.
484+ t (
485+ 'the inert generic finding rules out the root manifest by name' ,
486+ inertGeneric . problems [ 0 ] ?. includes ( 'ROOT package.json does NOT count' ) ,
487+ ) ;
488+ const nearGeneric = verdict ( { typechek : { } } , workspace ) ;
489+ t ( 'a misspelled generic key is a finding' , nearGeneric . problems . length === 1 ) ;
490+ t ( 'a misspelled generic key suggests the script that exists' , nearGeneric . problems [ 0 ] ?. includes ( 'typecheck' ) ) ;
491+
492+ t (
493+ 'holdersOf names every package declaring the script' ,
494+ holdersOf ( 'build' , workspace ) . join ( ',' ) === '@objectstack/plugin-auth,@objectstack/spec,create-objectstack' ,
495+ ) ;
496+ t ( 'holdersOf is empty for a script no package declares' , holdersOf ( 'test:e2e' , workspace ) . length === 0 ) ;
497+
393498 // ── Direction 2: the rule must stay GREEN on every legitimate shape ──
499+ // `gen:schema` stands where `test:e2e` used to: a generic key held by exactly
500+ // ONE package is legitimate, and this case is what keeps the limb above from
501+ // being satisfiable by "generic keys must be held by many".
394502 const clean = verdict (
395503 {
396504 build : { } ,
397- 'test:e2e ' : { } ,
505+ 'gen:schema ' : { } ,
398506 '@objectstack/plugin-auth#typecheck' : { } ,
399507 '@objectstack/spec#gen:schema' : { } ,
400508 'create-objectstack#test' : { } ,
401509 } ,
402510 workspace ,
403511 ) ;
404512 t ( 'legitimate package tasks are green' , clean . problems . length === 0 ) ;
405- t ( 'generic keys are out of population and not judged' , clean . judged === 3 ) ;
513+ t ( 'package-scoped keys are counted separately' , clean . judged === 3 ) ;
514+ t ( 'generic keys are in population and counted' , clean . genericJudged === 2 ) ;
406515
407516 // Parsing, pinned directly: every real key in this repo's turbo.json is one
408517 // of these three shapes, and a boundary taken anywhere but the FIRST `#`
@@ -435,6 +544,7 @@ export function selfTest() {
435544 try {
436545 const live = verdict ( readTasks ( ROOT ) , readWorkspaceScripts ( ROOT ) ) ;
437546 t ( 'the live turbo.json presents package tasks to judge' , live . judged > 0 ) ;
547+ t ( 'the live turbo.json presents generic tasks to judge' , live . genericJudged > 0 ) ;
438548 } catch ( err ) {
439549 failures . push ( `the live tree could not be read: ${ err . message } ` ) ;
440550 }
0 commit comments