315 lines
8.5 KiB
JavaScript
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();
|