#!/usr/bin/env node /* global process, console */ // Runs dependency-cruiser per workspace package to find circular imports, and fails only on // cycles that are not already recorded in the baseline file. Run with --update-baseline to // regenerate the baseline from the current state (e.g. after fixing a cycle). // // Exits 0 when the tree matches the baseline, 1 when a new cycle appeared, and 2 when the // baseline is merely stale. CI distinguishes the last two when reporting. import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); // Resolve dependency-cruiser's own entry point rather than the `.bin` shim, and run it with the // current Node binary. On Windows the shim is split into an extensionless shell script (which // `execFileSync` cannot launch) and a `.cmd` (which Node >=18 refuses to spawn without a shell, // see CVE-2024-27980), so going through `.bin` makes this script POSIX-only for no benefit. const depcruiseBin = path.join(repoRoot, 'node_modules', 'dependency-cruiser', 'bin', 'dependency-cruise.mjs'); const configPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dependency-cruiser.json'); const baselinePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'known-violations.json'); /** A cycle exists that the baseline does not record — a regression. */ const EXIT_NEW_CYCLES = 1; /** The baseline records cycles that no longer exist — it just needs regenerating. */ const EXIT_BASELINE_DRIFT = 2; // Same scope as `npm run lint`/`type-check`/`test` (--workspaces --if-present): the packages // actually declared as npm workspaces, not every directory under packages/ (e.g. // insomnia-component-docs is intentionally excluded and its deps aren't installed at the root). const rootPackageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); const packageDirs = rootPackageJson.workspaces .map(workspace => ({ name: path.basename(workspace), dir: path.join(repoRoot, workspace) })) .sort((a, b) => a.name.localeCompare(b.name)); const packageNames = packageDirs.map(({ name }) => name); function packageForAbsPath(absPath) { const found = packageDirs.find(({ dir }) => absPath === dir || absPath.startsWith(dir + path.sep)); return found ? found.name : null; } // Rotates a cycle's node list to start at its lexicographically smallest entry, so the same // logical cycle reported starting from different nodes (which dependency-cruiser does) collapses // to one canonical key. function canonicalize(repoRelNodes) { let minIndex = 0; for (let i = 1; i < repoRelNodes.length; i++) { if (repoRelNodes[i] < repoRelNodes[minIndex]) minIndex = i; } return [...repoRelNodes.slice(minIndex), ...repoRelNodes.slice(0, minIndex)].join(' -> '); } function cruisePackage({ name, dir }) { const tsconfigPath = path.join(dir, 'tsconfig.json'); const args = ['--config', configPath]; if (fs.existsSync(tsconfigPath)) args.push('--ts-config', 'tsconfig.json'); // Exclude node_modules and any local build/dist output (untracked, non-reproducible artifacts // from a prior local build) at any depth, e.g. packages/insomnia/build/. args.push('-x', 'node_modules|(^|/)(build|dist)(/|$)', '--output-type', 'json', '.'); let stdout; try { stdout = execFileSync(process.execPath, [depcruiseBin, ...args], { cwd: dir, encoding: 'utf8', maxBuffer: 1024 * 1024 * 100, }); } catch (error) { if (error?.status !== 1 || !error.stdout) throw error; // dependency-cruiser exits with status 1 when forbidden violations are found. Parse its JSON // output so the baseline comparison can decide whether those cycles are new. stdout = error.stdout; } const result = JSON.parse(stdout); // key: canonical signature -> Set of packages it touches const cycles = new Map(); for (const violation of result.summary.violations) { if (violation.type !== 'cycle') continue; // A cycle needs >=2 distinct modules; length-1 "cycles" are resolver artifacts (e.g. a bare // specifier like `esbuild` misresolved to a same-named local file `esbuild.ts`), not real // circular dependencies. if (violation.cycle.length <= 1) continue; const repoRelNodes = violation.cycle.map(node => { const abs = path.resolve(dir, node.name); return path.relative(repoRoot, abs).split(path.sep).join('/'); }); const key = canonicalize(repoRelNodes); if (cycles.has(key)) continue; const attributed = new Set(repoRelNodes.map(p => packageForAbsPath(path.resolve(repoRoot, p))).filter(Boolean)); cycles.set(key, attributed); } return cycles; } function cruiseAll() { // packageName -> Set const byPackage = new Map(packageNames.map(name => [name, new Set()])); for (const pkg of packageDirs) { const cycles = cruisePackage(pkg); for (const [key, attributed] of cycles) { for (const pkgName of attributed) { byPackage.get(pkgName).add(key); } } } return byPackage; } function readBaseline() { if (!fs.existsSync(baselinePath)) return new Map(); const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); return new Map(Object.entries(raw).map(([name, keys]) => [name, new Set(keys)])); } function writeBaseline(byPackage) { const raw = {}; for (const name of packageNames) { raw[name] = [...byPackage.get(name)].sort(); } fs.writeFileSync(baselinePath, JSON.stringify(raw, null, 2) + '\n'); } function main() { const updateBaseline = process.argv.includes('--update-baseline'); const current = cruiseAll(); if (updateBaseline) { writeBaseline(current); const total = [...current.values()].reduce((sum, set) => sum + set.size, 0); console.log( `Baseline updated: ${baselinePath} (${total} cycle attributions across ${packageNames.length} packages)`, ); return; } const baseline = readBaseline(); let hasNew = false; let hasDrift = false; for (const name of packageNames) { const currentKeys = current.get(name); const baselineKeys = baseline.get(name) || new Set(); const newKeys = [...currentKeys].filter(key => !baselineKeys.has(key)); const removedKeys = [...baselineKeys].filter(key => !currentKeys.has(key)); console.log( `${name}: ${currentKeys.size} cycle(s)${newKeys.length ? `, ${newKeys.length} NEW` : ''}${removedKeys.length ? `, ${removedKeys.length} REMOVED` : ''}`, ); if (newKeys.length) { hasNew = true; for (const key of newKeys) console.log(` NEW: ${key}`); } if (removedKeys.length) { hasDrift = true; for (const key of removedKeys) console.log(` REMOVED FROM BASELINE: ${key}`); } } if (hasNew || hasDrift) { if (hasNew) console.log('\nNew circular dependencies detected that are not in the baseline.'); if (hasDrift) console.log('\nBaseline contains circular dependencies no longer present in the current tree.'); console.log(`Run "npm run check-cycle-references:baseline" and commit the updated ${path.basename(baselinePath)}.`); // Both cases need the baseline regenerated, but they mean opposite things — a new cycle is a // regression, while drift alone means cycles were fixed and the baseline was left behind. // Exit 1 vs 2 so CI can say which happened instead of reporting every failure as "new". process.exit(hasNew ? EXIT_NEW_CYCLES : EXIT_BASELINE_DRIFT); } console.log('\nNo new circular dependencies.'); } main();