Initial TrueGrowth source import
This commit is contained in:
314
scripts/check-cycles.js
Normal file
314
scripts/check-cycles.js
Normal file
@@ -0,0 +1,314 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user