Skip to content

Commit c8d795a

Browse files
yinlianghuiclaude
andauthored
fix(turbo): repoint the inert test:e2e task at the smoke suite that exists, and judge generic task keys (#12468)
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04600d9 commit c8d795a

3 files changed

Lines changed: 138 additions & 28 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"dev:todo": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-todo dev",
1212
"spec:rebuild": "turbo run build --filter=...@objectstack/spec",
1313
"test": "VITEST_MAX_WORKERS=$(node scripts/vitest-worker-cap.mjs) turbo run test --concurrency=50%",
14-
"test:e2e": "turbo run test:e2e",
14+
"test:smoke": "turbo run test:smoke",
1515
"typecheck": "turbo run typecheck",
1616
"clean": "turbo run clean && rm -rf dist",
1717
"setup": "pnpm install && pnpm --filter @objectstack/spec build",

scripts/check-turbo-task-graph.mjs

Lines changed: 135 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,38 @@
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
*/
202244
export 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
}

turbo.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,10 +320,10 @@
320320
"$TURBO_ROOT$/packages/cli/src/commands/init.ts"
321321
]
322322
},
323-
"test:e2e": {
323+
"test:smoke": {
324324
"dependsOn": ["build"],
325325
"cache": false,
326-
"outputs": ["playwright-report/**", "test-results/**"],
326+
"outputs": ["test-results/**"],
327327
"inputs": ["src/**", "e2e/**", "playwright.config.ts"]
328328
},
329329
"dev": {

0 commit comments

Comments
 (0)