rules/eslint/closure-no-import-cycles.eslint.rule.ts
rules/eslint/closure-no-import-cycles.eslint.rule.ts is a file in GovLab Extension Host. 139 lines of code and 36 definitions.
import { GOVERNED_ROOT, normalizePath } from "../../shared/resolvers/anchor.resolver.ts";
import type { LocalRule, RuleContext, RuleListener } from "../../types/rule.types.ts";
import { loadClosureGraph, normalizeImport } from "../../shared/loaders/graph.loader.ts";
import type { ClosureGraph } from "../../types/closure.types.ts";
import { defineCheck } from "@govlab/context/check";
import { listener } from "../../shared/factories/listener.factory.ts";
type FileGraph = Map<string, Set<string>>;
const buildGraph = function buildGraph(closure: ClosureGraph): FileGraph {
const graph: FileGraph = new Map();
const addEdge = function addEdge(from: string, importStr: string): void {
const resolved = normalizeImport(from, importStr);
if (resolved === null) {
return;
}
const targets = graph.get(from) ?? new Set<string>();
targets.add(resolved);
graph.set(from, targets);
};
for (const imp of closure.imports) {
addEdge(imp.file, imp.from);
}
for (const imp of closure.sideEffectImports) {
addEdge(imp.file, imp.from);
}
return graph;
};
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
interface Frame {
idx: number;
node: string;
targets: string[] | null;
}
const cycleThrough = function cycleThrough(parent: Map<string, string>, from: string, target: string): string[] {
const path = [target];
let cursor: string | undefined = from;
while (cursor !== undefined && cursor !== target) {
path.push(cursor);
cursor = parent.get(cursor);
}
path.push(target);
return path.reverse();
};
const findCycles = function findCycles(graph: FileGraph): string[][] {
const color = new Map<string, number>();
const parent = new Map<string, string>();
const cycles: string[][] = [];
const visit = function visit(start: string): void {
const stack: Frame[] = [{ idx: 0, node: start, targets: null }];
color.set(start, GRAY);
const step = function step(frame: Frame): void {
frame.targets ??= [...(graph.get(frame.node) ?? [])];
const target = frame.targets[frame.idx];
if (target === undefined) {
color.set(frame.node, BLACK);
stack.pop();
return;
}
frame.idx += 1;
const seen = color.get(target) ?? WHITE;
if (seen === GRAY) {
cycles.push(cycleThrough(parent, frame.node, target));
return;
}
if (seen === WHITE) {
parent.set(target, frame.node);
color.set(target, GRAY);
stack.push({ idx: 0, node: target, targets: null });
}
};
while (stack.length > 0) {
const frame = stack.at(-1);
if (frame === undefined) {
break;
}
step(frame);
}
};
for (const node of graph.keys()) {
if ((color.get(node) ?? WHITE) === WHITE) {
visit(node);
}
}
return cycles;
};
const GRAPH = loadClosureGraph();
const CYCLES = GRAPH === null ? [] : findCycles(buildGraph(GRAPH));
const FILES_IN_CYCLES = ((): Map<string, string[]> => {
const byFile = new Map<string, string[]>();
for (const cycle of CYCLES) {
const repr = cycle.join(" → ");
for (const file of cycle) {
const chains = byFile.get(file) ?? [];
chains.push(repr);
byFile.set(file, chains);
}
}
return byFile;
})();
const MEMBER_PREFIX = `${normalizePath(GOVERNED_ROOT)}/`;
const relFromMember = function relFromMember(filepath: string): string {
const norm = normalizePath(filepath);
const idx = norm.indexOf(MEMBER_PREFIX);
return idx === -1 ? norm : norm.slice(idx + MEMBER_PREFIX.length);
};
export default {
create(context: RuleContext): RuleListener {
const rel = relFromMember(context.filename);
return listener({
program(_view, node) {
if (GRAPH === null) {
context.report({ messageId: "graphMissing", node });
return;
}
for (const chain of FILES_IN_CYCLES.get(rel) ?? []) {
context.report({ data: { chain }, messageId: "cycle", node });
}
},
});
},
meta: {
docs: {
checks: defineCheck({
detects: ["architecture:circular-dependency"],
enforces: ["architecture:directed-acyclic-graph"],
}),
description:
"Detects circular imports in the codebase. A file A that imports (directly or transitively) a file B that imports A creates a cycle — modules cannot be loaded deterministically, tree-shaking breaks, and the dependency direction is muddled. Cycles indicate misplaced shared concerns; lift the cycled state into a third file both can import.",
},
messages: {
cycle: "File participates in an import cycle: `{{ chain }}`. Lift the shared concern into a third file both ends can import without circling back.",
graphMissing:
"closure-graph missing. Run `npm run verify` to regenerate. Fail-close until the graph is available.",
},
schema: [],
type: "problem",
},
} satisfies LocalRule;