murashit_codex-panel/scripts/check-import-cycles.mjs
2026-06-13 21:55:13 +09:00

188 lines
5.6 KiB
JavaScript

import path from "node:path";
import ts from "typescript";
if (process.argv.length > 2) {
console.error("Usage: node scripts/check-import-cycles.mjs");
process.exit(1);
}
const cwd = process.cwd();
const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json");
if (!configPath) {
console.error("Could not find tsconfig.json.");
process.exit(1);
}
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
if (configFile.error) {
reportDiagnostic(configFile.error);
process.exit(1);
}
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
if (parsedConfig.errors.length > 0) {
for (const diagnostic of parsedConfig.errors) reportDiagnostic(diagnostic);
process.exit(1);
}
const sourceFiles = parsedConfig.fileNames.map((file) => path.resolve(file)).filter((file) => isCheckedSourceFile(relativePath(file)));
const sourceFileSet = new Set(sourceFiles);
const compilerHost = ts.createCompilerHost(parsedConfig.options, true);
const moduleResolutionCache = ts.createModuleResolutionCache(cwd, (file) => file, parsedConfig.options);
const graph = new Map(sourceFiles.map((file) => [relativePath(file), new Set()]));
for (const sourceFile of sourceFiles) {
const sourceText = ts.sys.readFile(sourceFile);
if (sourceText === undefined) continue;
const parsedSource = ts.createSourceFile(sourceFile, sourceText, parsedConfig.options.target ?? ts.ScriptTarget.ES2022, true);
const from = relativePath(sourceFile);
for (const edge of importEdges(parsedSource)) {
const resolved = ts.resolveModuleName(
edge.specifier,
sourceFile,
parsedConfig.options,
compilerHost,
moduleResolutionCache,
).resolvedModule;
if (!resolved) continue;
const resolvedFile = path.resolve(resolved.resolvedFileName);
if (!sourceFileSet.has(resolvedFile)) continue;
graph.get(from)?.add(relativePath(resolvedFile));
}
}
const cycleComponents = stronglyConnectedComponents(graph)
.filter((component) => component.length > 1 || hasSelfDependency(graph, component[0]))
.sort((left, right) => left[0].localeCompare(right[0]));
if (cycleComponents.length === 0) {
console.log("No import cycles found.");
process.exit(0);
}
console.error(`Import cycle check failed: found ${cycleComponents.length} cycle${cycleComponents.length === 1 ? "" : "s"}.`);
for (const [index, component] of cycleComponents.entries()) {
const cycle = sampleCycle(graph, component);
console.error(`${index + 1}. ${cycle.join(" -> ")}`);
}
process.exit(1);
function importEdges(sourceFile) {
const edges = [];
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement) && stringLiteralText(statement.moduleSpecifier)) {
edges.push({
specifier: stringLiteralText(statement.moduleSpecifier),
});
} else if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && stringLiteralText(statement.moduleSpecifier)) {
edges.push({
specifier: stringLiteralText(statement.moduleSpecifier),
});
}
}
return edges;
}
function stringLiteralText(node) {
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null;
}
function stronglyConnectedComponents(graph) {
let nextIndex = 0;
const stack = [];
const onStack = new Set();
const indices = new Map();
const lowLinks = new Map();
const components = [];
for (const node of graph.keys()) {
if (!indices.has(node)) visit(node);
}
return components.map((component) => component.sort());
function visit(node) {
indices.set(node, nextIndex);
lowLinks.set(node, nextIndex);
nextIndex += 1;
stack.push(node);
onStack.add(node);
for (const dependency of graph.get(node) ?? []) {
if (!indices.has(dependency)) {
visit(dependency);
lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(dependency)));
} else if (onStack.has(dependency)) {
lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(dependency)));
}
}
if (lowLinks.get(node) !== indices.get(node)) return;
const component = [];
let item;
do {
item = stack.pop();
onStack.delete(item);
component.push(item);
} while (item !== node);
components.push(component);
}
}
function sampleCycle(graph, component) {
const componentSet = new Set(component);
for (const start of component) {
if (graph.get(start)?.has(start)) return [start, start];
for (const dependency of graph.get(start) ?? []) {
if (!componentSet.has(dependency)) continue;
const pathToStart = findPath(graph, componentSet, dependency, start, new Set([start]));
if (pathToStart) return [start, ...pathToStart];
}
}
return component;
}
function findPath(graph, allowedNodes, current, target, seen) {
if (current === target) return [current];
if (seen.has(current)) return null;
seen.add(current);
for (const dependency of graph.get(current) ?? []) {
if (!allowedNodes.has(dependency)) continue;
const path = findPath(graph, allowedNodes, dependency, target, seen);
if (path) return [current, ...path];
}
return null;
}
function hasSelfDependency(graph, node) {
return Boolean(node && graph.get(node)?.has(node));
}
function isCheckedSourceFile(file) {
return (
file.startsWith("src/") &&
!file.startsWith("src/generated/") &&
(file.endsWith(".ts") || file.endsWith(".tsx")) &&
!file.endsWith(".d.ts")
);
}
function relativePath(file) {
return path.relative(cwd, file).split(path.sep).join("/");
}
function reportDiagnostic(diagnostic) {
console.error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
}