cat > gen-monorepo.cjs <<'GEN_EOF'
#!/usr/bin/env node
/**
* Synthetic monorepo generator for fallow performance reproductions.
*
* Usage:
* node gen-monorepo.cjs --workspaces 80 --files-per-ws 325 --root .
*
* All flags are documented in the inline comments below.
*/
const fs = require('node:fs');
const path = require('node:path');
const cp = require('node:child_process');
function arg(name, def) {
const i = process.argv.indexOf(name);
if (i === -1) return def;
const v = process.argv[i + 1];
return v === undefined ? true : v;
}
const N_WS = parseInt(arg('--workspaces', '20'), 10);
const FILES_PER_WS = parseInt(arg('--files-per-ws', '50'), 10);
const ROOT = path.resolve(arg('--root', '.'));
const IMPORTS_PER_FILE = parseInt(arg('--imports-per-file', '3'), 10);
const CROSS_WS = parseInt(arg('--cross-ws-imports', '0'), 10);
const COMMITS = parseInt(arg('--commits', '0'), 10);
const WRITE_CONFIGS = arg('--no-config-files', false) !== true;
const NEST_DEPTH = parseInt(arg('--nest-depth', '0'), 10);
const DEFAULT_PLUGINS = ['typescript', '@nx/workspace', 'eslint'];
const KITCHEN_SINK_PLUGINS = [
'vite', 'vitest', 'jest', '@storybook/react', '@nx/workspace',
'@angular/core', 'next', 'playwright', 'cypress', 'eslint',
'tailwindcss', 'react', 'react-router', 'remix', 'gatsby',
'nuxt', 'astro', 'rollup', 'webpack', 'parcel'
];
let pluginArg = arg('--plugins', DEFAULT_PLUGINS.join(','));
if (pluginArg === 'kitchen-sink') pluginArg = KITCHEN_SINK_PLUGINS.join(',');
const PLUGINS = String(pluginArg).split(',').filter(Boolean);
const PLUGIN_CONFIGS = {
vite: { file: 'vite.config.ts', body: 'export default { plugins: [] };\n' },
vitest: { file: 'vitest.config.ts', body: 'export default { test: { globals: true } };\n' },
jest: { file: 'jest.config.js', body: 'module.exports = { testEnvironment: "node" };\n' },
'@storybook/react': { file: '.storybook/main.ts', body: 'export default { stories: ["../src/**/*.stories.tsx"] };\n' },
'@nx/workspace': { file: 'project.json', body: '{ "name": "PKG", "sourceRoot": "src" }\n' },
'@angular/core': { file: 'angular.json', body: '{ "version": 1, "projects": {} }\n' },
next: { file: 'next.config.js', body: 'module.exports = {};\n' },
playwright: { file: 'playwright.config.ts', body: 'export default { testDir: "./e2e" };\n' },
cypress: { file: 'cypress.config.ts', body: 'export default { e2e: { baseUrl: "http://localhost" } };\n' },
eslint: { file: '.eslintrc.cjs', body: 'module.exports = { root: false, rules: {} };\n' },
tailwindcss: { file: 'tailwind.config.js', body: 'module.exports = { content: ["./src/**/*.{ts,tsx}"] };\n' },
react: null,
'react-router': null,
remix: { file: 'remix.config.js', body: 'module.exports = { ignoredRouteFiles: ["**/.*"] };\n' },
gatsby: { file: 'gatsby-config.ts', body: 'export default { plugins: [] };\n' },
nuxt: { file: 'nuxt.config.ts', body: 'export default {};\n' },
astro: { file: 'astro.config.mjs', body: 'export default {};\n' },
rollup: { file: 'rollup.config.js', body: 'export default { input: "src/index.ts", output: { file: "dist/index.js" } };\n' },
webpack: { file: 'webpack.config.js', body: 'module.exports = { entry: "./src/index.ts" };\n' },
parcel: { file: '.parcelrc', body: '{ "extends": "@parcel/config-default" }\n' }
};
function mkdir(p) { fs.mkdirSync(p, { recursive: true }); }
function write(p, body) { mkdir(path.dirname(p)); fs.writeFileSync(p, body); }
// Pinned, well-known stable versions so the maintainer can run npm install
// safely against the public npm registry. Pinning avoids any risk of pulling
// a pre-release with surprising postinstall scripts.
const PLUGIN_VERSIONS = {
vite: '5.4.10', vitest: '1.6.1', jest: '29.7.0',
'@storybook/react': '8.4.0', '@nx/workspace': '20.0.0', '@angular/core': '18.2.0',
next: '14.2.18', playwright: '1.48.0', cypress: '13.15.0',
eslint: '9.13.0', tailwindcss: '3.4.14', react: '18.3.1', 'react-router': '6.27.0',
remix: '1.19.3', gatsby: '5.13.7', nuxt: '3.13.2', astro: '4.16.0',
rollup: '4.24.0', webpack: '5.95.0', parcel: '2.12.0',
mocha: '10.7.3', ava: '6.1.3', typescript: '5.6.3',
};
function workspacePackage(i) {
const deps = {};
for (const p of PLUGINS) deps[p] = PLUGIN_VERSIONS[p] || '0.0.0';
return {
name: `@repro/pkg-${String(i).padStart(3, '0')}`,
version: '0.0.0', private: true,
main: 'src/index.ts', types: 'src/index.ts',
dependencies: deps
};
}
function fileBody(wsIdx, fileIdx) {
const lines = [];
for (let k = 1; k <= IMPORTS_PER_FILE; k++) {
const target = (fileIdx + k) % FILES_PER_WS;
if (target === fileIdx) continue;
lines.push(`import { fn${target} } from "./mod-${target}";`);
}
for (let k = 0; k < CROSS_WS && N_WS > 1; k++) {
const otherWs = (wsIdx + k + 1) % N_WS;
lines.push(`import { fn0 as cross${k} } from "@repro/pkg-${String(otherWs).padStart(3, '0')}";`);
}
lines.push('');
lines.push(`export function fn${fileIdx}(input: number): number {`);
lines.push(' let acc = input;');
lines.push(` if (acc > 10) acc += 1; else acc -= 1;`);
lines.push(` for (let i = 0; i < 3; i++) acc *= 2;`);
lines.push(' return acc;');
lines.push('}');
lines.push(`export const CONST_${fileIdx} = ${fileIdx};`);
return lines.join('\n') + '\n';
}
function fileSubdir(fileIdx) {
if (NEST_DEPTH <= 0) return 'src';
const segs = ['src'];
let n = fileIdx;
for (let d = 0; d < NEST_DEPTH; d++) {
segs.push(`g${n % 5}`);
n = Math.floor(n / 5);
}
return segs.join('/');
}
function indexBody(wsIdx) {
const lines = [];
for (let i = 0; i < FILES_PER_WS; i++) lines.push(`export * from "./mod-${i}";`);
return lines.join('\n') + '\n';
}
function writeConfigs(wsRoot) {
if (!WRITE_CONFIGS) return;
for (const p of PLUGINS) {
const cfg = PLUGIN_CONFIGS[p];
if (!cfg) continue;
write(path.join(wsRoot, cfg.file), cfg.body.replace(/PKG/g, path.basename(wsRoot)));
}
}
const WS_PARENT = String(arg('--workspace-parent', 'packages'));
const WORKSPACE_PATTERN = String(arg('--workspace-pattern', `${WS_PARENT}/*`));
console.log(`[gen] root=${ROOT} workspaces=${N_WS} files/ws=${FILES_PER_WS} plugins=${PLUGINS.length} pattern=${WORKSPACE_PATTERN} nest=${NEST_DEPTH}`);
mkdir(ROOT);
write(path.join(ROOT, 'package.json'), JSON.stringify({
name: 'fallow-perf-repro', version: '0.0.0', private: true,
workspaces: [WORKSPACE_PATTERN],
devDependencies: { typescript: '5.0.0' }
}, null, 2) + '\n');
write(path.join(ROOT, 'tsconfig.json'), JSON.stringify({
compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', strict: false, baseUrl: '.', paths: {} }
}, null, 2) + '\n');
for (let w = 0; w < N_WS; w++) {
const wsRoot = path.join(ROOT, WS_PARENT, `pkg-${String(w).padStart(3, '0')}`);
write(path.join(wsRoot, 'package.json'), JSON.stringify(workspacePackage(w), null, 2) + '\n');
write(path.join(wsRoot, 'src', 'index.ts'), indexBody(w));
for (let f = 0; f < FILES_PER_WS; f++) {
write(path.join(wsRoot, fileSubdir(f), `mod-${f}.ts`), fileBody(w, f));
}
writeConfigs(wsRoot);
}
if (COMMITS > 0) {
console.log(`[gen] initialising git repo with ${COMMITS} synthetic commits...`);
cp.execSync('git init -q', { cwd: ROOT, stdio: 'inherit' });
cp.execSync('git config user.email perf@example.com', { cwd: ROOT });
cp.execSync('git config user.name "Perf Reproducer"', { cwd: ROOT });
cp.execSync('git add -A', { cwd: ROOT, stdio: 'inherit' });
cp.execSync('git commit -q -m "initial"', { cwd: ROOT });
for (let c = 1; c <= COMMITS; c++) {
const w = c % N_WS;
const f = c % FILES_PER_WS;
const target = path.join(ROOT, WS_PARENT, `pkg-${String(w).padStart(3, '0')}`, fileSubdir(f), `mod-${f}.ts`);
fs.appendFileSync(target, `// touch ${c}\n`);
cp.execSync(`git -c user.email=perf${c % 5}@example.com -c user.name="Author${c % 5}" commit -q -am "touch ${c}"`, { cwd: ROOT });
}
}
const totalFiles = N_WS * (FILES_PER_WS + 1);
console.log(`[gen] done. Wrote ~${totalFiles} TS files across ${N_WS} workspaces.`);
GEN_EOF
I don't know fallow's internals well enough to predict what's actually achievable, but the per-stage breakdown above suggests several places where there might be some room. Some thoughts on each, in case any of them is helpful:
What happened?
On a monorepo that combines several common edge cases (many workspaces,
apps/**nesting, many framework plugins per workspace, large barrelindex.tsfiles, nonode_modulespresent), cold-cache runs offallow,fallow dead-code,fallow health, andfallow audit --base <ref>take ~20–30 seconds. To make this independently reproducible and easy to profile, I built a synthetic monorepo generator (200-line Node script, inlined below) that exercises the same edge cases and produces the same wall-clock on a clean machine.Timings (Apple Silicon M3 Max, fallow 2.52.0,
--no-cache, nonode_modules):fallow dead-codeanalyze3.1 s ·resolve imports2.9 s ·plugins1.4 s ·workspaces1.0 sfallow dupes(strict)--performanceblock emittedfallow health(default)duplication1.1 s ·git churn0.5 s ·parse0.5 s ·discover0.5 sfallow audit --base HEAD(zero changes)fallow audit --base <initial-commit>(full diff)dead-code + dupes + healthpipelinefallowbare (combined)dead-code + dupes + healthfallow --performanceblock:Edge cases the reproduction stresses
--workspaces 64workspacesstage scaling--files-per-ws 256discover+parse+analyzescaling--plugins next,gatsby,remix,vite,vitest,webpack,parcel,rolluppluginsstage scalingindex.tsre-exporting all 256 modulesentry points+analyzesuper-linear behaviour--imports-per-file 5 --cross-ws-imports 5resolve imports+ cross-workspace graph costsrc/g0/g1/g2/)--nest-depth 3apps/**workspace pattern--workspace-pattern 'apps/**' --workspace-parent apps--commits 200git churn/--hotspotscostReproduction
Prerequisites
v20.20.0was used)fallow2.52.0 (free tier, no license needed)Step 1 — create a clean directory
Step 2 — save the generator (200 lines, ~9 KB)
Step 3 — generate the synthetic project (~1 minute, ~120 MB on disk)
Expected output ends with:
Step 4 — reproduce the timings
Expected timings (Apple Silicon M3 Max, fallow 2.52.0, no
node_modules):fallow dead-codefallow dupesfallow healthfallow(combined)fallow audit --base <initial>Expected behavior
It would be really useful if
fallow auditcould fit inside a pre-commit hook on a large monorepo. To be usable as a pre-commit hook, the cold run on a workload like this would ideally need to take no longer than ~1-3 seconds — anything beyond that and developers tend to bypass the hook with--no-verify.I don't know fallow's internals well enough to predict what's actually achievable, but the per-stage breakdown above suggests several places where there might be some room. Some thoughts on each, in case any of them is helpful:
analyzeindex.ts). Possibly parallelisable per-moduleresolve importspluginsduplicationdead-codeinstead of re-tokenisingworkspacesgit churn--hotspotsisn't requested, whichauditdoesn't requestparse/extract(health)dead-codepipeline already did in the same processdiscover files(health)entry pointsFallow version
fallow 2.52.0
Operating system
macOS
Configuration