#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const ROOT_DIR = path.resolve(__dirname, '..');
const WEB_SRC_DIR = path.join(ROOT_DIR, 'src', 'web-ui', 'src');
const DEFAULT_ENTRY = path.join(WEB_SRC_DIR, 'main.tsx');
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
const HEAVY_PACKAGES = [
'monaco-editor',
'@monaco-editor/react',
'@xterm/xterm',
'@xterm/addon-fit',
'@xterm/addon-web-links',
'@xterm/addon-webgl',
'mermaid',
'react-syntax-highlighter',
'react-markdown',
'remark-gfm',
'remark-math',
'rehype-katex',
'rehype-raw',
'rehype-sanitize',
'katex',
'@tiptap/core',
'@tiptap/react',
'@tiptap/starter-kit',
'@tiptap/pm',
'lucide-react',
];
function parseArgs(argv) {
const args = {
entry: DEFAULT_ENTRY,
json: false,
top: 40,
assertNoDirectImports: [],
assertExternalUnreachable: [],
assertLocalPrefixUnreachable: [],
};
for (const arg of argv) {
if (arg === '--json') {
args.json = true;
} else if (arg.startsWith('--entry=')) {
args.entry = resolveRepoPath(arg.slice('--entry='.length));
} else if (arg.startsWith('--top=')) {
const value = Number(arg.slice('--top='.length));
if (Number.isInteger(value) && value > 0) {
args.top = value;
}
} else if (arg.startsWith('--assert-no-direct-import=')) {
const value = arg.slice('--assert-no-direct-import='.length);
const separatorIndex = value.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
throw new Error(`Invalid --assert-no-direct-import value: ${value}`);
}
args.assertNoDirectImports.push({
file: resolveRepoPath(value.slice(0, separatorIndex)),
specifier: value.slice(separatorIndex + 1),
});
} else if (arg.startsWith('--assert-external-unreachable=')) {
args.assertExternalUnreachable.push(arg.slice('--assert-external-unreachable='.length));
} else if (arg.startsWith('--assert-local-prefix-unreachable=')) {
args.assertLocalPrefixUnreachable.push(resolveRepoPath(arg.slice('--assert-local-prefix-unreachable='.length)));
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return args;
}
function resolveRepoPath(input) {
return path.isAbsolute(input) ? path.resolve(input) : path.resolve(ROOT_DIR, input);
}
function toRepoPath(filePath) {
return path.relative(ROOT_DIR, filePath).replace(/\\/g, '/');
}
function packageName(specifier) {
if (specifier.startsWith('@')) {
const [scope, name] = specifier.split('/');
return name ? `${scope}/${name}` : specifier;
}
return specifier.split('/')[0];
}
function importsOf(filePath) {
const source = fs.readFileSync(filePath, 'utf8');
const imports = [];
const staticImportPattern = /(?:^|\n)\s*import\s+(?!type\b)(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g;
const exportFromPattern = /(?:^|\n)\s*export\s+(?!type\b)(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/g;
let match;
while ((match = staticImportPattern.exec(source)) !== null) {
imports.push({ specifier: match[1], kind: 'import' });
}
while ((match = exportFromPattern.exec(source)) !== null) {
imports.push({ specifier: match[1], kind: 'export' });
}
return imports;
}
function resolveSpecifier(fromFile, specifier) {
let candidate;
if (specifier.startsWith('@/')) {
candidate = path.join(WEB_SRC_DIR, specifier.slice(2));
} else if (specifier === '@components') {
candidate = path.join(WEB_SRC_DIR, 'component-library', 'components');
} else if (specifier.startsWith('@components/')) {
candidate = path.join(WEB_SRC_DIR, 'component-library', 'components', specifier.slice('@components/'.length));
} else if (specifier.startsWith('.')) {
candidate = path.resolve(path.dirname(fromFile), specifier);
} else {
return { external: packageName(specifier) };
}
const candidates = [candidate];
for (const extension of SOURCE_EXTENSIONS) {
candidates.push(`${candidate}${extension}`);
}
for (const extension of SOURCE_EXTENSIONS) {
candidates.push(path.join(candidate, `index${extension}`));
}
for (const item of candidates) {
if (fs.existsSync(item) && fs.statSync(item).isFile()) {
return { file: path.resolve(item) };
}
}
return { unresolved: candidate };
}
function buildGraph(entry) {
if (!fs.existsSync(entry)) {
throw new Error(`Entry file does not exist: ${entry}`);
}
const seen = new Set();
const externalImporters = new Map();
const unresolved = [];
const edges = new Map();
function walk(filePath) {
const resolvedFile = path.resolve(filePath);
if (seen.has(resolvedFile)) {
return;
}
seen.add(resolvedFile);
const imports = importsOf(resolvedFile);
for (const item of imports) {
const resolved = resolveSpecifier(resolvedFile, item.specifier);
if (resolved.file) {
const list = edges.get(resolvedFile) || [];
list.push(resolved.file);
edges.set(resolvedFile, list);
walk(resolved.file);
} else if (resolved.external) {
const list = externalImporters.get(resolved.external) || [];
list.push(resolvedFile);
externalImporters.set(resolved.external, list);
} else {
unresolved.push({
importer: resolvedFile,
specifier: item.specifier,
resolvedTo: resolved.unresolved,
});
}
}
}
walk(entry);
const localModules = Array.from(seen).sort();
const largestLocalModules = localModules
.map((file) => ({
file,
bytes: fs.statSync(file).size,
}))
.sort((a, b) => b.bytes - a.bytes || a.file.localeCompare(b.file));
return {
entry,
localModules,
largestLocalModules,
externalImporters,
unresolved,
edges,
};
}
function runAssertions(args, graph) {
const failures = [];
for (const assertion of args.assertNoDirectImports) {
const imports = importsOf(assertion.file);
if (imports.some((item) => item.specifier === assertion.specifier)) {
failures.push(`${toRepoPath(assertion.file)} still directly imports ${assertion.specifier}`);
}
}
for (const pkg of args.assertExternalUnreachable) {
if (graph.externalImporters.has(pkg)) {
failures.push(`external package is still reachable from entry: ${pkg}`);
}
}
for (const prefix of args.assertLocalPrefixUnreachable) {
const normalizedPrefix = `${path.resolve(prefix)}${path.sep}`;
const matched = graph.localModules.filter((file) => file === path.resolve(prefix) || file.startsWith(normalizedPrefix));
if (matched.length > 0) {
failures.push(`local prefix is still reachable from entry: ${toRepoPath(prefix)} (${matched.length} modules)`);
}
}
if (failures.length > 0) {
for (const failure of failures) {
console.error(`[report-web-main-static-graph] assertion failed: ${failure}`);
}
process.exitCode = 1;
}
}
function serializableGraph(graph, top) {
const heavyExternals = {};
for (const pkg of HEAVY_PACKAGES) {
const importers = graph.externalImporters.get(pkg);
if (!importers) {
continue;
}
heavyExternals[pkg] = Array.from(new Set(importers)).map(toRepoPath);
}
return {
entry: toRepoPath(graph.entry),
localModuleCount: graph.localModules.length,
unresolved: graph.unresolved.map((item) => ({
importer: toRepoPath(item.importer),
specifier: item.specifier,
resolvedTo: toRepoPath(item.resolvedTo),
})),
heavyExternals,
largestLocalModules: graph.largestLocalModules.slice(0, top).map((item) => ({
file: toRepoPath(item.file),
bytes: item.bytes,
})),
};
}
function formatBytes(bytes) {
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KiB`;
}
function printReport(graph, args) {
const data = serializableGraph(graph, args.top);
console.log('Web main static graph report');
console.log(`Entry: ${data.entry}`);
console.log(`Local modules reachable: ${data.localModuleCount}`);
console.log(`Unresolved relative/alias imports: ${data.unresolved.length}`);
console.log('');
console.log('Heavy externals reachable:');
const heavyEntries = Object.entries(data.heavyExternals);
if (heavyEntries.length === 0) {
console.log('- none');
} else {
for (const [pkg, importers] of heavyEntries) {
console.log(`- ${pkg}: ${importers.length} import site(s)`);
for (const importer of importers.slice(0, 8)) {
console.log(` ${importer}`);
}
}
}
console.log('');
console.log(`Largest ${Math.min(args.top, data.largestLocalModules.length)} local modules in static graph:`);
for (const item of data.largestLocalModules) {
console.log(`${formatBytes(item.bytes).padStart(10)} ${item.file}`);
}
}
function main() {
try {
const args = parseArgs(process.argv.slice(2));
const graph = buildGraph(args.entry);
if (args.json) {
console.log(JSON.stringify(serializableGraph(graph, args.top), null, 2));
} else {
printReport(graph, args);
}
runAssertions(args, graph);
} catch (error) {
console.error(`[report-web-main-static-graph] ${error.message}`);
process.exitCode = 1;
}
}
main();