managarten/scripts/validate-no-recursive-turbo.mjs
Till JS 1eda3f5395 chore(turbo): lint against recursive \turbo run\ calls in child packages
CLAUDE.md flagged this as "CRITICAL" — a child package.json defining
e.g. \`"build": "turbo run build"\` causes a 10+ minute CI hang with
thousands of duplicate task spawns. The rule was documented but never
enforced, so it re-emerged every couple of months as someone copied a
parent script pattern.

- \`scripts/validate-no-recursive-turbo.mjs\` walks every tracked
  package.json (via \`git ls-files\`, so node_modules is auto-skipped)
  and fails if any non-root package has build/type-check/lint/test/
  test:coverage/check scripts containing \`turbo run\`. \`dev\` stays
  allowed — delegating it from a parent is the intended ergonomic.
- Wired as \`pnpm run validate:turbo\` + a new CI step in the validate
  job (before type-check — fails fast).
- CLAUDE.md §Turborepo updated to point at the enforcer and call out
  the full task list (test/test:coverage/check were missing from the
  original prose).

Verified: 138 non-root package.json files scan clean. Drift simulation
(injecting \`"build": "turbo run build"\` into apps/mana/apps/web) fails
with a clear message pointing at the offending file + script + fix.

This closes audit item #32 from the architecture review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:39:32 +02:00

91 lines
3.1 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* Validate that no non-root package.json calls `turbo run` in a script
* that turbo itself orchestrates. Those recursive calls are a 10+ minute
* build hang waiting to happen — see the root CLAUDE.md §Turborepo for
* the incident that prompted this rule.
*
* Rule: inside any package.json EXCEPT the repo root, the scripts
* { build, type-check, lint, test, test:coverage }
* must NOT contain `turbo run`. Turbo drives them from the root; if a
* child also calls turbo, you get N² task spawns, duplicate work, and
* eventually a hung CI job.
*
* `dev` is explicitly allowed: it's persistent, usually scoped via
* --filter, and delegating it from a parent package is the intended
* ergonomic shortcut (e.g. `pnpm mana:dev` → turbo run dev --filter=mana...).
*
* Zero deps — runs as plain Node ESM. Uses `git ls-files` so it
* automatically respects .gitignore (no node_modules traversal).
*/
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
/** Scripts that turbo orchestrates from the root. Recursive turbo calls
* in these scripts are what cause the build hang. */
const FORBIDDEN_TASKS = new Set(['build', 'type-check', 'lint', 'test', 'test:coverage', 'check']);
function listPackageJsons() {
const out = execSync('git ls-files "package.json" "**/package.json"', {
cwd: REPO_ROOT,
encoding: 'utf8',
});
return out
.split('\n')
.map((p) => p.trim())
.filter(Boolean);
}
function validate() {
const paths = listPackageJsons();
const rootRel = 'package.json';
const violations = [];
let scanned = 0;
for (const rel of paths) {
if (rel === rootRel) continue; // root is ALLOWED to orchestrate turbo
scanned++;
const abs = join(REPO_ROOT, rel);
let pkg;
try {
pkg = JSON.parse(readFileSync(abs, 'utf8'));
} catch (err) {
violations.push(`${rel}: invalid JSON (${err.message})`);
continue;
}
const scripts = pkg.scripts ?? {};
for (const [task, cmd] of Object.entries(scripts)) {
if (!FORBIDDEN_TASKS.has(task)) continue;
if (typeof cmd !== 'string') continue;
// Match `turbo run` as a whole word — don't trip on strings like
// "turbot run" (hypothetical) or unrelated tooling.
if (/\bturbo\s+run\b/.test(cmd)) {
violations.push(
`${rel}: script '${task}' calls \`turbo run\` — this causes recursion when ` +
`the root turbo also runs '${task}'. Replace with the direct tool ` +
`(e.g. \`tsc --noEmit\`, \`vite build\`) or remove the script entirely.`
);
}
}
}
if (violations.length > 0) {
console.error(`\n✗ Recursive-turbo check FAILED (${violations.length} violation(s)):\n`);
for (const v of violations) console.error(`${v}`);
console.error(
`\nSee CLAUDE.md §Turborepo for context. Allowed: \`dev\` scripts may call ` +
`\`turbo run dev --filter=…\` (persistent + scoped).\n`
);
process.exit(1);
}
console.log(`✓ No recursive turbo calls: scanned ${scanned} non-root package.json files.`);
}
validate();