singularity-forge/scripts/check-circular-deps.mjs
2026-05-18 00:13:31 +02:00

282 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
/**
* check-circular-deps.mjs — detect circular imports across the SF codebase.
*
* Uses the workspace's installed TypeScript compiler API to parse JS/TS/MJS
* files, build a directed import graph, then find cycles via Tarjan's
* strongly-connected-components algorithm. Single-node SCCs that have a
* self-loop are also reported.
*
* Replaces madge for SF — madge declares `peerDeps.typescript: "^5.4.4"`
* and bundles its own `typescript@5.9.3` via `dependency-tree`, which
* conflicted with the repo's `typescript@6.0.3` and cluttered the docker
* build install. This walker uses the exact TS the rest of the repo uses,
* so there is zero version drift.
*
* Usage:
* npm run check:circular # scan src/ + packages/
* npm run check:circular -- --ext # extension source only
* node scripts/check-circular-deps.mjs [--ext] [--json]
*
* Exit 0 = no cycles found. Exit 1 = cycles detected (or scan error).
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, extname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
const args = process.argv.slice(2);
const extOnly = args.includes("--ext");
const jsonOut = args.includes("--json");
const entries = extOnly
? [resolve(root, "src/resources/extensions/sf")]
: [resolve(root, "src"), resolve(root, "packages")];
const SOURCE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
const SKIP_DIR = new Set(["node_modules", "dist", "build", "coverage", ".next"]);
const SKIP_FILE = (name) =>
name.endsWith(".test.js") ||
name.endsWith(".test.mjs") ||
name.endsWith(".test.ts") ||
name.endsWith(".test.tsx") ||
name.endsWith(".d.ts") ||
name.endsWith(".d.ts.map");
console.error(
`Scanning: ${entries.map((e) => relative(root, e)).join(", ")}`,
);
/** Collect source files under each entry, skipping tests / d.ts / dist / etc. */
function collectSourceFiles(entry) {
const out = [];
const stack = [entry];
while (stack.length > 0) {
const current = stack.pop();
let st;
try {
st = statSync(current);
} catch {
continue;
}
if (st.isDirectory()) {
let names;
try {
names = readdirSync(current);
} catch {
continue;
}
for (const name of names) {
if (SKIP_DIR.has(name)) continue;
if (name.startsWith(".")) continue; // skip dotted dirs/files
stack.push(join(current, name));
}
} else if (st.isFile()) {
if (!SOURCE_EXTS.has(extname(current))) continue;
const base = current.split("/").pop() ?? "";
if (SKIP_FILE(base)) continue;
if (current.includes("/tests/") || current.includes("/test/")) continue;
out.push(current);
}
}
return out;
}
const allFiles = entries.flatMap(collectSourceFiles);
const fileSet = new Set(allFiles);
/**
* Try to resolve a relative import specifier from `fromFile` to an actual
* file on disk. Returns the resolved absolute path, or null if it doesn't
* resolve into our scanned set (which is what we want — only intra-graph
* edges count for cycle detection).
*/
function resolveImport(fromFile, spec) {
if (!spec.startsWith(".") && !spec.startsWith("/")) return null; // ignore bare/npm
const fromDir = dirname(fromFile);
const baseAbs = resolve(fromDir, spec);
// Try exact, then ext substitution, then index files.
const candidates = [
baseAbs,
`${baseAbs}.ts`,
`${baseAbs}.tsx`,
`${baseAbs}.mts`,
`${baseAbs}.cts`,
`${baseAbs}.js`,
`${baseAbs}.mjs`,
`${baseAbs}.cjs`,
join(baseAbs, "index.ts"),
join(baseAbs, "index.tsx"),
join(baseAbs, "index.js"),
join(baseAbs, "index.mjs"),
];
// Also handle TS's .js → .ts substitution (common in ESM TS code that imports
// "./foo.js" while the source file is foo.ts).
if (spec.endsWith(".js")) {
candidates.push(`${baseAbs.replace(/\.js$/, "")}.ts`);
candidates.push(`${baseAbs.replace(/\.js$/, "")}.tsx`);
candidates.push(`${baseAbs.replace(/\.js$/, "")}.mts`);
}
for (const c of candidates) {
if (fileSet.has(c)) return c;
if (existsSync(c)) {
try {
if (statSync(c).isFile() && SOURCE_EXTS.has(extname(c))) return c;
} catch {
/* ignore */
}
}
}
return null;
}
/** Extract import specifiers from a source file via the TS compiler. */
function extractImports(filePath) {
let source;
try {
source = readFileSync(filePath, "utf-8");
} catch {
return [];
}
const scriptKind =
filePath.endsWith(".tsx") || filePath.endsWith(".jsx")
? ts.ScriptKind.TSX
: undefined;
const sf = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ false,
scriptKind,
);
const specs = [];
const visit = (node) => {
// import X from "..." | import "..." | import { X } from "..."
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
specs.push(node.moduleSpecifier.text);
}
// export { X } from "..." | export * from "..."
if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
specs.push(node.moduleSpecifier.text);
}
// dynamic import("...") | await import("...")
if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length > 0 &&
ts.isStringLiteral(node.arguments[0])
) {
specs.push(node.arguments[0].text);
}
// CommonJS require("...")
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "require" &&
node.arguments.length > 0 &&
ts.isStringLiteral(node.arguments[0])
) {
specs.push(node.arguments[0].text);
}
ts.forEachChild(node, visit);
};
visit(sf);
return specs;
}
/** Build the import graph: file → Set of imported files within the scanned set. */
const graph = new Map();
for (const file of allFiles) {
const edges = new Set();
for (const spec of extractImports(file)) {
const resolved = resolveImport(file, spec);
if (resolved && fileSet.has(resolved) && resolved !== file) {
edges.add(resolved);
}
}
graph.set(file, edges);
}
/**
* Tarjan's strongly-connected-components algorithm. Any SCC of size > 1
* is a cycle; size-1 SCCs with a self-edge are also cycles.
*/
function tarjanSCC(g) {
let index = 0;
const indices = new Map();
const lowlinks = new Map();
const onStack = new Set();
const stack = [];
const sccs = [];
const strongConnect = (v) => {
indices.set(v, index);
lowlinks.set(v, index);
index += 1;
stack.push(v);
onStack.add(v);
for (const w of g.get(v) ?? []) {
if (!indices.has(w)) {
strongConnect(w);
lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
} else if (onStack.has(w)) {
lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
}
}
if (lowlinks.get(v) === indices.get(v)) {
const scc = [];
let w;
do {
w = stack.pop();
onStack.delete(w);
scc.push(w);
} while (w !== v);
sccs.push(scc);
}
};
for (const v of g.keys()) {
if (!indices.has(v)) strongConnect(v);
}
return sccs;
}
const sccs = tarjanSCC(graph);
const cycles = [];
for (const scc of sccs) {
if (scc.length > 1) {
cycles.push(scc.map((p) => relative(root, p)));
} else if (scc.length === 1) {
const v = scc[0];
if (graph.get(v)?.has(v)) cycles.push([relative(root, v)]);
}
}
if (jsonOut) {
console.log(JSON.stringify({ cycles, count: cycles.length }, null, 2));
} else if (cycles.length === 0) {
console.log(`✅ No circular dependencies found. (scanned ${allFiles.length} files)`);
} else {
console.log(`${cycles.length} circular dependency chain(s) found:\n`);
for (const [i, chain] of cycles.entries()) {
console.log(` ${i + 1}. ${chain.join(" → ")}${chain[0]}`);
}
}
process.exit(cycles.length > 0 ? 1 : 0);