130 lines
4.9 KiB
JavaScript
130 lines
4.9 KiB
JavaScript
// Detects Turborepo cache bypass patterns that cause every commit to rebuild every project,
|
|
// driving Build Minutes to dominate the bill on monorepos.
|
|
//
|
|
// Three signal subtypes:
|
|
// force-flag — `TURBO_FORCE=true` env var or `turbo run ... --force` in build script
|
|
// cache-disabled — `turbo.json` declares `"cache": false` for the build pipeline
|
|
// no-ignore-step — repo has turbo.json and no repo-declared ignoreCommand;
|
|
// verify Vercel's skip-unaffected project setting before recommending one
|
|
//
|
|
// This pattern has caused full-monorepo rebuilds on every commit. Build-skip
|
|
// settings and right-sized build machines can reduce Build Minutes when the
|
|
// project is rebuilding unchanged work.
|
|
|
|
export const metadata = {
|
|
id: 'turbo-force-bypass',
|
|
title: 'Turborepo cache bypass on a monorepo',
|
|
severity: 'high',
|
|
billingDimension: 'build',
|
|
trafficIndependent: true, // build-time, fires regardless of route traffic
|
|
description:
|
|
"Turborepo's per-task cache can be bypassed by an explicit force flag, a `cache: false` config, or missing build-skip configuration. Every commit can rebuild unchanged work; Build Minutes climb with project count.",
|
|
fix:
|
|
"Remove `TURBO_FORCE=true` from build env/scripts unless intentional. Set `tasks.build.cache: true` in `turbo.json` (or remove the override), and include generated outputs in Turbo's cache contract. Prefer Vercel's skip-unaffected monorepo behavior when available; use `ignoreCommand` only when that setting cannot cover the project.",
|
|
citations: [
|
|
'https://vercel.com/docs/monorepos',
|
|
'https://vercel.com/docs/builds',
|
|
'https://turborepo.dev/docs/crafting-your-repository/caching',
|
|
],
|
|
excludeGlobs: ['node_modules/**', '.next/**', 'dist/**'],
|
|
includeGlobs: ['turbo.json', '**/turbo.json', 'package.json', '**/package.json', 'vercel.json', '**/vercel.json'],
|
|
};
|
|
|
|
const FORCE_ENV_RE = /TURBO_FORCE\s*=\s*(?:true|1)\b/;
|
|
const FORCE_FLAG_RE = /\bturbo\s+(?:run\s+)?[a-z:_-]+[^\n&|;]*\s--force\b/;
|
|
|
|
export function scan({ files }) {
|
|
const out = [];
|
|
let hasTurboJson = false;
|
|
let vercelJsonFile = null;
|
|
let vercelJsonContent = null;
|
|
|
|
for (const { path, content } of files) {
|
|
const name = path.split('/').pop();
|
|
|
|
if (name === 'turbo.json') {
|
|
hasTurboJson = true;
|
|
const buildCacheDisabled = detectBuildCacheDisabled(content);
|
|
if (buildCacheDisabled) {
|
|
out.push({
|
|
pattern: metadata.id,
|
|
file: path,
|
|
line: buildCacheDisabled.line,
|
|
evidence: 'turbo.json: tasks.build.cache = false',
|
|
trafficIndependent: metadata.trafficIndependent,
|
|
subtype: 'cache-disabled',
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (name === 'package.json') {
|
|
const scripts = safeScripts(content);
|
|
for (const [scriptName, body] of Object.entries(scripts)) {
|
|
if (FORCE_ENV_RE.test(body) || FORCE_FLAG_RE.test(body)) {
|
|
const line = lineOfMatch(content, body) ?? 1;
|
|
out.push({
|
|
pattern: metadata.id,
|
|
file: path,
|
|
line,
|
|
evidence: `package.json scripts.${scriptName}: ${truncate(body, 80)}`,
|
|
trafficIndependent: metadata.trafficIndependent,
|
|
subtype: 'force-flag',
|
|
});
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (name === 'vercel.json') {
|
|
vercelJsonFile = path;
|
|
vercelJsonContent = content;
|
|
}
|
|
}
|
|
|
|
// No-ignore-step: repo has turbo.json AND vercel.json lacks an ignoreCommand.
|
|
// This is an investigation prompt, not proof that the dashboard skip setting is off.
|
|
if (hasTurboJson && vercelJsonFile && !/"ignoreCommand"\s*:/.test(vercelJsonContent ?? '')) {
|
|
out.push({
|
|
pattern: metadata.id,
|
|
file: vercelJsonFile,
|
|
line: 1,
|
|
evidence: 'turbo repo without ignoreCommand in vercel.json; verify Vercel skip-unaffected setting',
|
|
trafficIndependent: metadata.trafficIndependent,
|
|
subtype: 'no-ignore-step',
|
|
});
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function detectBuildCacheDisabled(content) {
|
|
// Tolerate JSONC comments and trailing commas — light scan, not full parse.
|
|
// Match `"build": { ... "cache": false ... }` within reasonable lookahead.
|
|
const buildTask = /"build"\s*:\s*\{([\s\S]{0,400}?)\}/.exec(content);
|
|
if (!buildTask) return null;
|
|
if (!/"cache"\s*:\s*false/.test(buildTask[1])) return null;
|
|
const lineNum = content.slice(0, buildTask.index).split('\n').length;
|
|
return { line: lineNum };
|
|
}
|
|
|
|
function safeScripts(content) {
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
return parsed?.scripts && typeof parsed.scripts === 'object' ? parsed.scripts : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function lineOfMatch(haystack, needle) {
|
|
const idx = haystack.indexOf(needle);
|
|
if (idx < 0) return null;
|
|
return haystack.slice(0, idx).split('\n').length;
|
|
}
|
|
|
|
function truncate(s, n) {
|
|
if (typeof s !== 'string') return '';
|
|
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
}
|