mirror of
https://github.com/Kong/insomnia.git
synced 2026-10-07 13:33:27 -04:00
Two independent papercuts in the circular reference check, both hit while investigating a failure on another PR. `npm run check-cycle-references` could not run on Windows at all. It spawned the extensionless `node_modules/.bin/depcruise` shim through `execFileSync`, which Windows cannot launch; the `.cmd` sibling does not help either, since Node >=18 refuses to spawn `.cmd` without a shell (CVE-2024-27980). Resolve dependency-cruiser's own entry point and run it with `process.execPath` instead — no shell, works everywhere. The check also fails on baseline drift (cycles recorded in the baseline that no longer exist), which is correct: the baseline is a ratchet, and leaving a fixed cycle in it would silently keep permitting its reintroduction. But CI labelled every non-success outcome "New circular references detected", so a PR that *removed* two cycles was reported as having added some. That cost real debugging time. Give the script distinct exit codes — 1 for a new cycle, 2 for drift alone — and have the workflow capture and map them to separate messages. The failure behaviour is unchanged; only the wording is now accurate. Renamed the final step to match what it actually gates on. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
171 lines
7.5 KiB
JavaScript
Executable File
171 lines
7.5 KiB
JavaScript
Executable File
#!/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<canonical key>
|
|
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();
|