Files
omniai-web/scripts/dynamic-analysis-v2.mjs
stringadmin 4a298d205b
Web Quality / verify (push) Has been cancelled
chore: reduce frontend lint warnings
2026-06-09 12:02:30 +08:00

307 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]
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]);
}
}
}
}
}
for (const file of importMap.keys()) {
findCircular(file);
}
// Check high-fanin files (imported by many)
const fanIn = new Map();
for (const imports of importMap.values()) {
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 imports of importMap.values()) {
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`);
}
if (emptyDeps > 0) {
console.log(` [MOUNT-EFFECT] ${r.file}: ${emptyDeps} useEffect(s) run on mount only`);
}
}
// ─── 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('═══════════════════════════════════════════════');