Cycle 2 (the 13-node coding-agent mega) closed via two changes:
1. scripts/check-circular-deps.mjs — track function-body depth and
skip require()/import() calls inside function bodies. They run on
call, not at module evaluation, and therefore cannot cause
module-graph cycles — same reasoning as the existing dynamic
`await import()` skip. Generic improvement; benefits any pattern
that uses lazy CommonJS require() to break a static cycle.
2. packages/coding-agent/src/core/extensions/loader.ts — removed the
static `import * as _bundledCodingAgent from "../../index.js"`
self-reference, which was the cycle-closer. It only populated
STATIC_BUNDLED_MODULES for the Bun virtualModules path
(`isBunBinary` branch in getJitiOptions), and SF is Node-26-only
per operator policy (no Bun) — so the Bun branch is dead at
runtime and dropping the static self-reference is safe. The two
map entries that referenced it (@singularity-forge/coding-agent
and the @mariozechner alias) are commented out at the same site
with a pointer to the top-of-file note.
Net effect across the full session:
start of session: 9 cycles
walker false-positive
cleanups landed: dropped 6 type-only + dynamic-import false
positives
tui ↔ overlay-layout: CURSOR_MARKER moved to overlay-types.ts
SF autonomous-rollback
chain (3 targeted
cuts): experimental → preferences-serializer,
classifier → lazy rollback import,
preferences-models → runaway-defaults.js
this commit: coding-agent loader self-reference dropped
Final state: ✅ zero circular dependencies in 1193 scanned files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
328 lines
9.9 KiB
JavaScript
328 lines
9.9 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 = [];
|
|
|
|
// Track function-body depth so we can skip imports/requires that run only
|
|
// when their enclosing function is called (i.e. not at module evaluation
|
|
// time). These cannot cause module-graph cycles for the same reason
|
|
// dynamic `await import()` cannot. Counts cover FunctionDeclaration,
|
|
// FunctionExpression, ArrowFunction, MethodDeclaration, accessors, and
|
|
// class member functions.
|
|
let functionDepth = 0;
|
|
const FN_KINDS = new Set([
|
|
ts.SyntaxKind.FunctionDeclaration,
|
|
ts.SyntaxKind.FunctionExpression,
|
|
ts.SyntaxKind.ArrowFunction,
|
|
ts.SyntaxKind.MethodDeclaration,
|
|
ts.SyntaxKind.GetAccessor,
|
|
ts.SyntaxKind.SetAccessor,
|
|
ts.SyntaxKind.Constructor,
|
|
]);
|
|
const visit = (node) => {
|
|
const isFn = FN_KINDS.has(node.kind);
|
|
if (isFn) functionDepth += 1;
|
|
try {
|
|
visitNode(node);
|
|
} finally {
|
|
if (isFn) functionDepth -= 1;
|
|
}
|
|
};
|
|
const visitNode = (node) => {
|
|
// import X from "..." | import "..." | import { X } from "..."
|
|
//
|
|
// Skip top-level type-only imports (`import type { X } from "..."`) —
|
|
// TypeScript erases them at compile time and they cannot cause runtime
|
|
// cycles. Same reasoning as skipping dynamic imports below.
|
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
const isTypeOnly = node.importClause?.isTypeOnly === true;
|
|
if (!isTypeOnly) {
|
|
// Also skip if EVERY named specifier is marked `type` individually
|
|
// — that's the `import { type X, type Y } from "..."` shape.
|
|
const namedBindings = node.importClause?.namedBindings;
|
|
const allSpecifiersTypeOnly =
|
|
namedBindings &&
|
|
ts.isNamedImports(namedBindings) &&
|
|
namedBindings.elements.length > 0 &&
|
|
namedBindings.elements.every((e) => e.isTypeOnly === true) &&
|
|
// If there's a default-import binding (importClause.name), that's
|
|
// a runtime binding even if all named ones are type-only.
|
|
!node.importClause?.name;
|
|
if (!allSpecifiersTypeOnly) {
|
|
specs.push(node.moduleSpecifier.text);
|
|
}
|
|
}
|
|
}
|
|
// export { X } from "..." | export * from "..." — skip `export type`
|
|
if (
|
|
ts.isExportDeclaration(node) &&
|
|
node.moduleSpecifier &&
|
|
ts.isStringLiteral(node.moduleSpecifier) &&
|
|
node.isTypeOnly !== true
|
|
) {
|
|
specs.push(node.moduleSpecifier.text);
|
|
}
|
|
// Intentionally skip dynamic `import("...")` and `await import("...")`.
|
|
// Lazy/async imports are a legitimate cycle-breaking technique:
|
|
// they don't run at module-graph evaluation time, so they cannot
|
|
// cause initialization-order cycles. Madge's prior config used
|
|
// `skipAsyncImports: true` for the same reason — matching that
|
|
// here so we don't false-positive on intentional lazy seams.
|
|
// CommonJS require("...") — only count top-level requires; require()
|
|
// inside a function body runs lazily on call, same semantics as
|
|
// dynamic `await import()` and cannot cause module-graph cycles.
|
|
if (
|
|
functionDepth === 0 &&
|
|
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);
|