mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
214 lines
6.4 KiB
JavaScript
214 lines
6.4 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) && hasRuntimeImportEdge(statement) && stringLiteralText(statement.moduleSpecifier)) {
|
|
edges.push({
|
|
specifier: stringLiteralText(statement.moduleSpecifier),
|
|
});
|
|
} else if (
|
|
ts.isExportDeclaration(statement) &&
|
|
hasRuntimeExportEdge(statement) &&
|
|
statement.moduleSpecifier &&
|
|
stringLiteralText(statement.moduleSpecifier)
|
|
) {
|
|
edges.push({
|
|
specifier: stringLiteralText(statement.moduleSpecifier),
|
|
});
|
|
}
|
|
}
|
|
|
|
return edges;
|
|
}
|
|
|
|
function hasRuntimeImportEdge(statement) {
|
|
const importClause = statement.importClause;
|
|
if (!importClause) return true;
|
|
if (importClause.isTypeOnly) return false;
|
|
if (importClause.name) return true;
|
|
|
|
const namedBindings = importClause.namedBindings;
|
|
if (!namedBindings) return false;
|
|
if (ts.isNamespaceImport(namedBindings)) return true;
|
|
return namedBindings.elements.some((element) => !element.isTypeOnly);
|
|
}
|
|
|
|
function hasRuntimeExportEdge(statement) {
|
|
if (statement.isTypeOnly) return false;
|
|
|
|
const exportClause = statement.exportClause;
|
|
if (!exportClause) return true;
|
|
if (ts.isNamespaceExport(exportClause)) return true;
|
|
return exportClause.elements.some((element) => !element.isTypeOnly);
|
|
}
|
|
|
|
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"));
|
|
}
|