302 lines
14 KiB
JavaScript
302 lines
14 KiB
JavaScript
/**
|
|
* Dynamic analysis without Playwright - uses Node.js to analyze module structure,
|
|
* dependency graph, import patterns, and potential runtime costs.
|
|
*/
|
|
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
import { join, relative, basename } from 'path';
|
|
|
|
const SRC = join(import.meta.dirname, '..', 'src');
|
|
const DIST = join(import.meta.dirname, '..', 'dist');
|
|
|
|
const results = [];
|
|
function walk(dir) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory() && entry.name !== 'node_modules') walk(full);
|
|
else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
const content = readFileSync(full, 'utf-8');
|
|
results.push({ file: relative(join(SRC, '..'), full), content, lines: content.split('\n').length });
|
|
}
|
|
}
|
|
}
|
|
walk(SRC);
|
|
|
|
// ─── 1. Dependency Graph Analysis ───
|
|
console.log('═══════════════════════════════════════════════');
|
|
console.log(' 1. MODULE DEPENDENCY GRAPH ANALYSIS');
|
|
console.log('═══════════════════════════════════════════════');
|
|
|
|
const importMap = new Map(); // file -> [imports]
|
|
const importedBy = new Map(); // file -> [importers]
|
|
|
|
for (const r of results) {
|
|
const imports = [];
|
|
// Match import statements
|
|
const importRe = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
let m;
|
|
while ((m = importRe.exec(r.content)) !== null) {
|
|
imports.push(m[1]);
|
|
}
|
|
// Match dynamic imports
|
|
const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
while ((m = dynRe.exec(r.content)) !== null) {
|
|
imports.push(`[dynamic]${m[1]}`);
|
|
}
|
|
importMap.set(r.file, imports);
|
|
}
|
|
|
|
// Find circular dependencies
|
|
console.log('\n--- Circular Dependency Detection ---');
|
|
function findCircular(file, visited = new Set(), path = []) {
|
|
if (visited.has(file)) {
|
|
if (path.includes(file)) {
|
|
console.log(` [CIRCULAR] ${path.slice(path.indexOf(file)).join(' -> ')} -> ${file}`);
|
|
}
|
|
return;
|
|
}
|
|
visited.add(file);
|
|
path.push(file);
|
|
const deps = importMap.get(file) || [];
|
|
for (const dep of deps) {
|
|
if (dep.startsWith('.') || dep.startsWith('/')) {
|
|
// Resolve relative path
|
|
const dir = file.split('/').slice(0, -1).join('/');
|
|
const resolved = dep.replace(/^\.\//, dir + '/').replace(/^\.\.\//, '');
|
|
// Find matching file
|
|
for (const r of results) {
|
|
if (r.file.includes(resolved) || r.file.includes(basename(resolved))) {
|
|
findCircular(r.file, new Set(visited), [...path]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check high-fanin files (imported by many)
|
|
const fanIn = new Map();
|
|
for (const [file, imports] of importMap) {
|
|
for (const imp of imports) {
|
|
const key = imp.replace(/\[dynamic\]/, '');
|
|
fanIn.set(key, (fanIn.get(key) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
console.log('\n--- High Fan-In Modules (most imported) ---');
|
|
const sortedFanIn = [...fanIn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
for (const [mod, count] of sortedFanIn) {
|
|
const bar = '█'.repeat(Math.min(30, count));
|
|
console.log(` ${mod.padEnd(50)} ${String(count).padStart(3)}x ${bar}`);
|
|
}
|
|
|
|
// Check high-fanout files (import many)
|
|
console.log('\n--- High Fan-Out Modules (import most) ---');
|
|
const sortedFanOut = [...importMap.entries()]
|
|
.map(([f, imps]) => [f, imps.length])
|
|
.sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
for (const [file, count] of sortedFanOut) {
|
|
const bar = '█'.repeat(Math.min(30, count));
|
|
console.log(` ${file.padEnd(50)} ${String(count).padStart(3)} deps ${bar}`);
|
|
}
|
|
|
|
// Dynamic imports analysis (lazy loading effectiveness)
|
|
console.log('\n--- Lazy Loading (Dynamic Imports) ---');
|
|
let dynamicImports = 0, staticImports = 0;
|
|
for (const [file, imports] of importMap) {
|
|
for (const imp of imports) {
|
|
if (imp.startsWith('[dynamic]')) dynamicImports++;
|
|
else staticImports++;
|
|
}
|
|
}
|
|
console.log(` Static imports: ${staticImports}`);
|
|
console.log(` Dynamic imports: ${dynamicImports}`);
|
|
console.log(` Lazy load ratio: ${((dynamicImports / (staticImports + dynamicImports)) * 100).toFixed(1)}%`);
|
|
|
|
// Find files that should be lazy loaded but aren't
|
|
const largePages = results.filter(r => r.lines > 500 && r.file.includes('Page'));
|
|
for (const r of largePages) {
|
|
const isLazyImported = [...importMap.values()].some(imps =>
|
|
imps.some(i => i.startsWith('[dynamic]') && i.includes(basename(r.file, '.tsx')))
|
|
);
|
|
if (!isLazyImported && !r.file.includes('App')) {
|
|
// Check if it's referenced in App.tsx
|
|
const appContent = results.find(x => x.file === 'src/App.tsx')?.content || '';
|
|
if (appContent.includes(basename(r.file, '.tsx'))) {
|
|
console.log(` [INFO] ${r.file} (${r.lines} lines) - loaded via App.tsx`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 2. React Rendering Cost Analysis ───
|
|
console.log('\n═══════════════════════════════════════════════');
|
|
console.log(' 2. REACT RENDERING COST ANALYSIS');
|
|
console.log('═══════════════════════════════════════════════');
|
|
|
|
// Count useState/useReducer per component (state update triggers re-render)
|
|
console.log('\n--- State Hook Density ---');
|
|
for (const r of results) {
|
|
if (!r.file.endsWith('.tsx')) continue;
|
|
const stateHooks = (r.content.match(/useState\s*[<(]/g) || []).length;
|
|
const reducers = (r.content.match(/useReducer\s*[<(]/g) || []).length;
|
|
const effects = (r.content.match(/useEffect\s*\(/g) || []).length;
|
|
const memos = (r.content.match(/useMemo\s*\(/g) || []).length;
|
|
const callbacks = (r.content.match(/useCallback\s*[<(]/g) || []).length;
|
|
const refs = (r.content.match(/useRef\s*[<(]/g) || []).length;
|
|
|
|
const totalHooks = stateHooks + reducers + effects + memos + callbacks + refs;
|
|
if (totalHooks > 15) {
|
|
const risk = totalHooks > 30 ? '🔴 HIGH' : totalHooks > 20 ? '🟡 MEDIUM' : '🟢 LOW';
|
|
console.log(` ${risk} ${r.file}`);
|
|
console.log(` useState:${stateHooks} useReducer:${reducers} useEffect:${effects} useMemo:${memos} useCallback:${callbacks} useRef:${refs} (total:${totalHooks})`);
|
|
|
|
// Check if there are many state updates that could be batched
|
|
const setters = r.content.match(/set\w+\(/g) || [];
|
|
if (setters.length > 20) {
|
|
console.log(` ⚠️ ${setters.length} state setter calls — potential for excessive re-renders`);
|
|
}
|
|
|
|
// Check for missing useMemo on expensive computations
|
|
const expensiveInRender = (r.content.match(/\.map\(|\.filter\(|\.reduce\(|\.sort\(/g) || []).length;
|
|
if (expensiveInRender > 5 && memos === 0) {
|
|
console.log(` ⚠️ ${expensiveInRender} array operations in render body with 0 useMemo`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 3. useEffect Dependency Analysis ───
|
|
console.log('\n═══════════════════════════════════════════════');
|
|
console.log(' 3. useEffect DEPENDENCY ANALYSIS');
|
|
console.log('═══════════════════════════════════════════════');
|
|
|
|
for (const r of results) {
|
|
if (!r.file.endsWith('.tsx')) continue;
|
|
// Find useEffect with no dependency array (runs every render)
|
|
const noDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*\)/g) || []).length;
|
|
// Find useEffect with empty deps
|
|
const emptyDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*,\s*\[\s*\]\s*\)/g) || []).length;
|
|
|
|
if (noDeps > 0) {
|
|
console.log(` [RENDER-COST] ${r.file}: ${noDeps} useEffect(s) run EVERY render`);
|
|
}
|
|
}
|
|
|
|
// ─── 4. Zustand Store Analysis ───
|
|
console.log('\n═══════════════════════════════════════════════');
|
|
console.log(' 4. ZUSTAND STORE ANALYSIS');
|
|
console.log('═══════════════════════════════════════════════');
|
|
|
|
const storeFiles = results.filter(r => r.file.includes('store') || r.file.includes('Store'));
|
|
for (const r of storeFiles) {
|
|
const stateFields = (r.content.match(/^\s+\w+:/gm) || []).length;
|
|
const actions = (r.content.match(/^\s+\w+\s*:\s*(\(|function|\w+\s*=>)/gm) || []).length;
|
|
const subscribers = (r.content.match(/subscribe\s*\(/g) || []).length;
|
|
|
|
console.log(`\n ${r.file}`);
|
|
console.log(` State fields: ~${stateFields}`);
|
|
console.log(` Actions: ~${actions}`);
|
|
console.log(` Subscribers: ${subscribers}`);
|
|
|
|
// Check for selector usage patterns
|
|
if (r.content.includes('set(') && !r.content.includes('useShallow')) {
|
|
console.log(` ⚠️ Uses set() without useShallow — may cause unnecessary re-renders`);
|
|
}
|
|
}
|
|
|
|
// ─── 5. Bundle Composition Analysis ───
|
|
console.log('\n═══════════════════════════════════════════════');
|
|
console.log(' 5. BUNDLE COMPOSITION ANALYSIS');
|
|
console.log('═══════════════════════════════════════════════');
|
|
|
|
if (readdirSync(DIST).includes('assets')) {
|
|
const assets = readdirSync(join(DIST, 'assets'));
|
|
const jsFiles = assets.filter(f => f.endsWith('.js') && !f.endsWith('.br'));
|
|
const cssFiles = assets.filter(f => f.endsWith('.css') && !f.endsWith('.br'));
|
|
|
|
let totalJs = 0, totalCss = 0;
|
|
const chunks = [];
|
|
for (const f of jsFiles) {
|
|
const size = statSync(join(DIST, 'assets', f)).size;
|
|
totalJs += size;
|
|
chunks.push({ name: f, sizeKB: size / 1024, type: 'js' });
|
|
}
|
|
for (const f of cssFiles) {
|
|
const size = statSync(join(DIST, 'assets', f)).size;
|
|
totalCss += size;
|
|
chunks.push({ name: f, sizeKB: size / 1024, type: 'css' });
|
|
}
|
|
|
|
chunks.sort((a, b) => b.sizeKB - a.sizeKB);
|
|
|
|
console.log(`\n Total JS: ${(totalJs / 1024).toFixed(1)} KB (${(totalJs / 1024 / 1024).toFixed(2)} MB)`);
|
|
console.log(` Total CSS: ${(totalCss / 1024).toFixed(1)} KB (${(totalCss / 1024 / 1024).toFixed(2)} MB)`);
|
|
console.log(` Total: ${((totalJs + totalCss) / 1024).toFixed(1)} KB`);
|
|
|
|
// CSS is suspiciously large
|
|
const cssRatio = totalCss / totalJs;
|
|
if (cssRatio > 0.5) {
|
|
console.log(`\n ⚠️ CSS/JS ratio: ${(cssRatio * 100).toFixed(0)}% — CSS bundle may contain unused styles`);
|
|
console.log(` Consider: PurgeCSS, CSS Modules, or extracting to separate loads`);
|
|
}
|
|
|
|
// Check initial load budget
|
|
const initialChunks = chunks.filter(c =>
|
|
c.name.includes('index') || c.name.includes('vendor-react') || c.name.includes('vendor-antd')
|
|
);
|
|
const initialLoad = initialChunks.reduce((s, c) => s + c.sizeKB, 0);
|
|
console.log(`\n Critical path (initial load):`);
|
|
for (const c of initialChunks) {
|
|
console.log(` ${c.name.padEnd(45)} ${c.sizeKB.toFixed(1).padStart(8)} KB`);
|
|
}
|
|
console.log(` ${'TOTAL'.padEnd(45)} ${initialLoad.toFixed(1).padStart(8)} KB`);
|
|
|
|
if (initialLoad > 300) {
|
|
console.log(`\n ⚠️ Initial load > 300KB — consider code splitting or tree-shaking`);
|
|
}
|
|
}
|
|
|
|
// ─── 6. API Client Efficiency ───
|
|
console.log('\n═══════════════════════════════════════════════');
|
|
console.log(' 6. API CLIENT EFFICIENCY');
|
|
console.log('═══════════════════════════════════════════════');
|
|
|
|
const apiFiles = results.filter(r => r.file.includes('src/api/'));
|
|
for (const r of apiFiles) {
|
|
const fetchCalls = (r.content.match(/fetch\s*\(/g) || []).length;
|
|
const retries = (r.content.match(/retry|Retry|RETRY/g) || []).length;
|
|
const abortSignals = (r.content.match(/AbortSignal|AbortController/g) || []).length;
|
|
const timeouts = (r.content.match(/timeout|Timeout|setTimeout/g) || []).length;
|
|
|
|
if (fetchCalls > 0) {
|
|
console.log(`\n ${r.file}`);
|
|
console.log(` fetch calls: ${fetchCalls}`);
|
|
console.log(` retry logic: ${retries > 0 ? '✅' : '❌ none'}`);
|
|
console.log(` abort signals: ${abortSignals > 0 ? '✅' : '❌ none'}`);
|
|
console.log(` timeouts: ${timeouts}`);
|
|
}
|
|
}
|
|
|
|
// ─── 7. TypeScript Compilation Metrics ───
|
|
console.log('\n═══════════════════════════════════════════════');
|
|
console.log(' 7. TYPE COMPLEXITY');
|
|
console.log('═══════════════════════════════════════════════');
|
|
|
|
let totalTypes = 0, totalInterfaces = 0, totalEnums = 0, totalGenerics = 0;
|
|
for (const r of results) {
|
|
totalTypes += (r.content.match(/^export\s+type\s+/gm) || []).length;
|
|
totalTypes += (r.content.match(/^type\s+/gm) || []).length;
|
|
totalInterfaces += (r.content.match(/^export\s+interface\s+/gm) || []).length;
|
|
totalInterfaces += (r.content.match(/^interface\s+/gm) || []).length;
|
|
totalEnums += (r.content.match(/enum\s+\w+/g) || []).length;
|
|
totalGenerics += (r.content.match(/<\w+(?:\s*,\s*\w+)*>/g) || []).length;
|
|
}
|
|
|
|
console.log(` Type aliases: ${totalTypes}`);
|
|
console.log(` Interfaces: ${totalInterfaces}`);
|
|
console.log(` Enums: ${totalEnums}`);
|
|
console.log(` Generic usages: ${totalGenerics}`);
|
|
console.log(` Total TS files: ${results.length}`);
|
|
|
|
// ─── Final Summary ───
|
|
console.log('\n═══════════════════════════════════════════════');
|
|
console.log(' ANALYSIS COMPLETE');
|
|
console.log('═══════════════════════════════════════════════');
|