From 1eda3f539560e64775f84aec84943d78c8e37040 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 20 Apr 2026 14:39:32 +0200 Subject: [PATCH] chore(turbo): lint against recursive \`turbo run\` calls in child packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/ci.yml | 3 + CLAUDE.md | 4 +- package.json | 1 + scripts/validate-no-recursive-turbo.mjs | 91 +++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100755 scripts/validate-no-recursive-turbo.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 097031b40..203dd9c47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -440,6 +440,9 @@ jobs: - name: Validate monorepo best practices run: pnpm run validate:monorepo + - name: Validate no recursive turbo calls + run: pnpm run validate:turbo + - name: Audit crypto registry (Dexie ↔ registry ↔ allowlist) run: pnpm run check:crypto diff --git a/CLAUDE.md b/CLAUDE.md index efcfcb9cb..caadac77e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,9 @@ MinIO (Docker, S3-compatible) in both local and prod. Console: http://localhost: ### Turborepo: avoid recursive turbo calls -**CRITICAL**: Parent workspace packages (e.g. `apps/chat/package.json`) must NEVER define `type-check`, `build`, or `lint` scripts that call `turbo run `. Root turbo already orchestrates those — defining them in children causes infinite recursion (10+ minute hangs, thousands of duplicate tasks). Only `dev` is OK to delegate to turbo from a parent package, since it's persistent and typically scoped. +**CRITICAL**: Parent workspace packages (e.g. `apps/chat/package.json`) must NEVER define `type-check`, `build`, `lint`, `test`, `test:coverage`, or `check` scripts that call `turbo run `. Root turbo already orchestrates those — defining them in children causes infinite recursion (10+ minute hangs, thousands of duplicate tasks). Only `dev` is OK to delegate to turbo from a parent package, since it's persistent and typically scoped. + +Enforced by `pnpm run validate:turbo` (`scripts/validate-no-recursive-turbo.mjs`), wired into the CI `validate` job — a new `turbo run build` inside a non-root package.json now fails the PR. ## Shared Packages (`packages/`) diff --git a/package.json b/package.json index 300fe83d5..0628ac79f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format:check": "prettier --config .prettierrc.json --check \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"", "check:status": "bash scripts/check-status.sh", "validate:dockerfiles": "node scripts/validate-dockerfiles.mjs", + "validate:turbo": "node scripts/validate-no-recursive-turbo.mjs", "check:crypto": "node scripts/audit-crypto-registry.mjs", "check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed", "audit:deps": "node scripts/audit-workspace-deps.mjs", diff --git a/scripts/validate-no-recursive-turbo.mjs b/scripts/validate-no-recursive-turbo.mjs new file mode 100755 index 000000000..f64cb7e3e --- /dev/null +++ b/scripts/validate-no-recursive-turbo.mjs @@ -0,0 +1,91 @@ +#!/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();