Files
TrueGrowth/scripts/check-cycles.js

315 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const ts = require('typescript');
const root = process.cwd();
const includeTypes = process.argv.includes('--include-types');
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'];
const IGNORED_DIRS = new Set([
'node_modules',
'dist',
'coverage',
'__snapshots__',
'.nx',
'.vite',
]);
function toPosix(value) {
return value.split(path.sep).join('/');
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function getScanRoots() {
const roots = [path.join(root, 'apps/web/src')];
const packagesDir = path.join(root, 'packages');
if (fs.existsSync(packagesDir)) {
for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const srcDir = path.join(packagesDir, entry.name, 'src');
if (fs.existsSync(srcDir)) {
roots.push(srcDir);
}
}
}
return roots;
}
function walk(dir, files) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.') || IGNORED_DIRS.has(entry.name)) continue;
const filePath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(filePath, files);
continue;
}
if (
SOURCE_EXTENSIONS.includes(path.extname(entry.name)) &&
!entry.name.endsWith('.d.ts')
) {
files.push(path.normalize(filePath));
}
}
}
function createPathAliases() {
const tsconfigPath = path.join(root, 'tsconfig.base.json');
if (!fs.existsSync(tsconfigPath)) return [];
const paths = readJson(tsconfigPath).compilerOptions?.paths || {};
return Object.entries(paths).flatMap(([alias, targets]) => {
return targets.map((target) => {
const wildcardIndex = alias.indexOf('*');
return {
alias,
target,
hasWildcard: wildcardIndex >= 0,
prefix: wildcardIndex >= 0 ? alias.slice(0, wildcardIndex) : alias,
suffix: wildcardIndex >= 0 ? alias.slice(wildcardIndex + 1) : '',
targetPrefix:
target.indexOf('*') >= 0 ? target.slice(0, target.indexOf('*')) : target,
targetSuffix:
target.indexOf('*') >= 0 ? target.slice(target.indexOf('*') + 1) : '',
};
});
});
}
function createResolver(fileSet) {
const aliases = createPathAliases();
function tryFile(basePath) {
const candidates = [];
if (path.extname(basePath)) candidates.push(basePath);
for (const extension of SOURCE_EXTENSIONS) {
candidates.push(basePath + extension);
}
for (const extension of SOURCE_EXTENSIONS) {
candidates.push(path.join(basePath, `index${extension}`));
}
for (const candidate of candidates) {
const normalized = path.normalize(candidate);
if (fileSet.has(normalized)) return normalized;
}
return null;
}
function resolveAlias(specifier) {
for (const alias of aliases) {
if (!alias.hasWildcard && specifier === alias.alias) {
const resolved = tryFile(path.join(root, alias.target));
if (resolved) return resolved;
}
if (
alias.hasWildcard &&
specifier.startsWith(alias.prefix) &&
specifier.endsWith(alias.suffix)
) {
const matched = specifier.slice(
alias.prefix.length,
specifier.length - alias.suffix.length
);
const resolved = tryFile(
path.join(root, `${alias.targetPrefix}${matched}${alias.targetSuffix}`)
);
if (resolved) return resolved;
}
}
return null;
}
return function resolveSpecifier(specifier, importer) {
if (specifier.startsWith('.')) {
return tryFile(path.resolve(path.dirname(importer), specifier));
}
return resolveAlias(specifier);
};
}
function isNamedBindingsTypeOnly(namedBindings) {
if (!namedBindings || !ts.isNamedImports(namedBindings)) {
return false;
}
return namedBindings.elements.every((item) => item.isTypeOnly);
}
function isImportTypeOnly(node) {
const clause = node.importClause;
if (!clause) return false;
if (clause.isTypeOnly) return true;
if (clause.name || !clause.namedBindings) return false;
return isNamedBindingsTypeOnly(clause.namedBindings);
}
function isExportTypeOnly(node) {
if (node.isTypeOnly) return true;
const clause = node.exportClause;
if (!clause || !ts.isNamedExports(clause)) return false;
return clause.elements.length > 0 && clause.elements.every((item) => item.isTypeOnly);
}
function collectStaticImports(filePath, resolveSpecifier) {
const source = fs.readFileSync(filePath, 'utf8');
const extension = path.extname(filePath);
const scriptKind =
extension === '.tsx'
? ts.ScriptKind.TSX
: extension === '.jsx'
? ts.ScriptKind.JSX
: extension === '.js' || extension === '.mjs' || extension === '.cjs'
? ts.ScriptKind.JS
: ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
scriptKind
);
const imports = [];
function add(specifier, kind) {
const resolved = resolveSpecifier(specifier, filePath);
if (resolved) imports.push({ specifier, file: resolved, kind });
}
function visit(node) {
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const isTypeOnly = isImportTypeOnly(node);
if (includeTypes || !isTypeOnly) {
add(
node.moduleSpecifier.text,
isTypeOnly ? 'type-static' : 'runtime-static'
);
}
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const isTypeOnly = isExportTypeOnly(node);
if (includeTypes || !isTypeOnly) {
add(
node.moduleSpecifier.text,
isTypeOnly ? 'type-static' : 'runtime-static'
);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return imports;
}
function findStronglyConnectedComponents(graph) {
let index = 0;
const stack = [];
const onStack = new Set();
const indexes = new Map();
const lowlinks = new Map();
const components = [];
function visit(node) {
indexes.set(node, index);
lowlinks.set(node, index);
index += 1;
stack.push(node);
onStack.add(node);
for (const next of graph.get(node) || []) {
if (!graph.has(next)) continue;
if (!indexes.has(next)) {
visit(next);
lowlinks.set(node, Math.min(lowlinks.get(node), lowlinks.get(next)));
} else if (onStack.has(next)) {
lowlinks.set(node, Math.min(lowlinks.get(node), indexes.get(next)));
}
}
if (lowlinks.get(node) === indexes.get(node)) {
const component = [];
let current;
do {
current = stack.pop();
onStack.delete(current);
component.push(current);
} while (current !== node);
if (component.length > 1) {
components.push(component);
}
}
}
for (const node of graph.keys()) {
if (!indexes.has(node)) visit(node);
}
return components.sort((a, b) => b.length - a.length);
}
function formatFile(filePath) {
return toPosix(path.relative(root, filePath));
}
function main() {
const files = [];
getScanRoots().forEach((scanRoot) => walk(scanRoot, files));
const fileSet = new Set(files);
const resolveSpecifier = createResolver(fileSet);
const graph = new Map();
const edgeDetails = new Map();
for (const file of files) {
const imports = collectStaticImports(file, resolveSpecifier);
graph.set(
file,
imports.map((item) => item.file)
);
edgeDetails.set(file, imports);
}
const cycles = findStronglyConnectedComponents(graph);
const modeLabel = includeTypes ? 'static import' : 'static runtime import';
if (cycles.length === 0) {
console.log(`[check-cycles] No ${modeLabel} cycles found.`);
return;
}
console.error(
`[check-cycles] Found ${cycles.length} ${modeLabel} cycle(s).`
);
cycles.forEach((component, index) => {
const members = new Set(component);
console.error(`\n#${index + 1} ${component.length} files`);
for (const file of [...component].sort((a, b) =>
formatFile(a).localeCompare(formatFile(b))
)) {
for (const edge of edgeDetails.get(file) || []) {
if (members.has(edge.file)) {
console.error(
`- ${formatFile(file)} --[${edge.kind}:${edge.specifier}]--> ${formatFile(edge.file)}`
);
}
}
}
});
process.exitCode = 1;
}
main();