mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 06:14:59 +00:00
295 lines
8.0 KiB
TypeScript
295 lines
8.0 KiB
TypeScript
#!/usr/bin/env tsx
|
|
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
import { basename, extname, join, relative } from 'path'
|
|
|
|
interface HookCounts {
|
|
[key: string]: number
|
|
}
|
|
|
|
interface ComponentMetrics {
|
|
file: string
|
|
component: string
|
|
lines: number
|
|
bytes: number
|
|
hooks: {
|
|
builtIn: number
|
|
custom: number
|
|
total: number
|
|
byHook: HookCounts
|
|
}
|
|
effects: number
|
|
memoization: number
|
|
estimatedRenderTimeMs: number
|
|
reasons: string[]
|
|
risk: 'low' | 'medium' | 'high'
|
|
}
|
|
|
|
const BUILTIN_HOOKS = [
|
|
'useState',
|
|
'useReducer',
|
|
'useEffect',
|
|
'useLayoutEffect',
|
|
'useInsertionEffect',
|
|
'useMemo',
|
|
'useCallback',
|
|
'useRef',
|
|
'useContext',
|
|
'useSyncExternalStore',
|
|
'useTransition',
|
|
'useDeferredValue',
|
|
'useId',
|
|
'useImperativeHandle',
|
|
]
|
|
|
|
const BUILTIN_HOOK_SET = new Set(BUILTIN_HOOKS)
|
|
const SKIP_DIRS = new Set([
|
|
'node_modules',
|
|
'.next',
|
|
'dist',
|
|
'build',
|
|
'coverage',
|
|
'.git',
|
|
'__tests__',
|
|
'__mocks__',
|
|
'__snapshots__',
|
|
])
|
|
|
|
const THRESHOLDS = {
|
|
slowRenderMs: 16,
|
|
largeComponentLines: 200,
|
|
veryLargeComponentLines: 300,
|
|
highHookCount: 12,
|
|
highEffectCount: 3,
|
|
}
|
|
|
|
const TARGET_EXTENSIONS = new Set(['.tsx'])
|
|
|
|
function countMatches(content: string, regex: RegExp): number {
|
|
return content.match(regex)?.length ?? 0
|
|
}
|
|
|
|
function pickSourceRoot(): string | null {
|
|
const candidates = [
|
|
process.env.RENDER_ANALYSIS_ROOT,
|
|
join(process.cwd(), 'frontends', 'nextjs', 'src'),
|
|
join(process.cwd(), 'src'),
|
|
].filter(Boolean) as string[]
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function walkDir(dir: string, files: string[]): void {
|
|
let entries: string[]
|
|
try {
|
|
entries = readdirSync(dir)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = join(dir, entry)
|
|
let stats
|
|
try {
|
|
stats = statSync(fullPath)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
if (stats.isDirectory()) {
|
|
if (SKIP_DIRS.has(entry)) {
|
|
continue
|
|
}
|
|
walkDir(fullPath, files)
|
|
continue
|
|
}
|
|
|
|
if (!stats.isFile()) {
|
|
continue
|
|
}
|
|
|
|
if (!TARGET_EXTENSIONS.has(extname(entry))) {
|
|
continue
|
|
}
|
|
|
|
if (entry.endsWith('.test.tsx') || entry.endsWith('.spec.tsx') || entry.endsWith('.stories.tsx')) {
|
|
continue
|
|
}
|
|
|
|
files.push(fullPath)
|
|
}
|
|
}
|
|
|
|
function estimateRenderTimeMs(lines: number, hooks: number, effects: number, memoization: number): number {
|
|
const base = 1.5
|
|
const lineCost = Math.min(lines, 400) * 0.03
|
|
const hookCost = hooks * 0.4
|
|
const effectCost = effects * 0.8
|
|
const memoSavings = Math.min(memoization, 4) * 0.3
|
|
const estimate = base + lineCost + hookCost + effectCost - memoSavings
|
|
return Math.max(0.5, Math.round(estimate * 10) / 10)
|
|
}
|
|
|
|
function analyzeFile(filePath: string): ComponentMetrics | null {
|
|
let content = ''
|
|
try {
|
|
content = readFileSync(filePath, 'utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const lines = content.split(/\r?\n/).length
|
|
const bytes = Buffer.byteLength(content, 'utf8')
|
|
|
|
const byHook: HookCounts = {}
|
|
let builtInCount = 0
|
|
|
|
for (const hook of BUILTIN_HOOKS) {
|
|
const count = countMatches(content, new RegExp(`\\b${hook}\\b`, 'g'))
|
|
byHook[hook] = count
|
|
builtInCount += count
|
|
}
|
|
|
|
const allHookCalls = content.match(/\buse[A-Z]\w*\b/g) ?? []
|
|
const customHookCount = Math.max(0, allHookCalls.filter(hook => !BUILTIN_HOOK_SET.has(hook)).length)
|
|
const hookCount = builtInCount + customHookCount
|
|
|
|
const effectCount = (byHook.useEffect ?? 0) + (byHook.useLayoutEffect ?? 0) + (byHook.useInsertionEffect ?? 0)
|
|
const memoCount = (byHook.useMemo ?? 0) + (byHook.useCallback ?? 0)
|
|
const reactMemoCount = countMatches(content, /\bReact\.memo\b/g)
|
|
const memoCallCount = countMatches(content, /\bmemo\s*\(/g)
|
|
const memoization = memoCount + reactMemoCount + Math.max(0, memoCallCount - reactMemoCount)
|
|
|
|
const estimatedRenderTimeMs = estimateRenderTimeMs(lines, hookCount, effectCount, memoization)
|
|
const reasons: string[] = []
|
|
|
|
if (lines >= THRESHOLDS.veryLargeComponentLines) {
|
|
reasons.push(`Very large component: ${lines} lines`)
|
|
} else if (lines >= THRESHOLDS.largeComponentLines) {
|
|
reasons.push(`Large component: ${lines} lines`)
|
|
}
|
|
|
|
if (hookCount >= THRESHOLDS.highHookCount) {
|
|
reasons.push(`High hook count: ${hookCount}`)
|
|
}
|
|
|
|
if (effectCount >= THRESHOLDS.highEffectCount) {
|
|
reasons.push(`Multiple effects: ${effectCount}`)
|
|
}
|
|
|
|
if (estimatedRenderTimeMs >= THRESHOLDS.slowRenderMs) {
|
|
reasons.push(`Estimated render time: ${estimatedRenderTimeMs}ms`)
|
|
}
|
|
|
|
let risk: ComponentMetrics['risk'] = 'low'
|
|
if (reasons.length >= 3 || estimatedRenderTimeMs >= THRESHOLDS.slowRenderMs) {
|
|
risk = 'high'
|
|
} else if (reasons.length >= 1) {
|
|
risk = 'medium'
|
|
}
|
|
|
|
return {
|
|
file: relative(process.cwd(), filePath),
|
|
component: basename(filePath, '.tsx'),
|
|
lines,
|
|
bytes,
|
|
hooks: {
|
|
builtIn: builtInCount,
|
|
custom: customHookCount,
|
|
total: hookCount,
|
|
byHook,
|
|
},
|
|
effects: effectCount,
|
|
memoization,
|
|
estimatedRenderTimeMs,
|
|
reasons,
|
|
risk,
|
|
}
|
|
}
|
|
|
|
function buildRecommendations(slowComponents: ComponentMetrics[]): string[] {
|
|
const recommendations: string[] = []
|
|
|
|
if (slowComponents.length === 0) {
|
|
recommendations.push('No high-risk components detected. Re-run after significant UI changes.')
|
|
return recommendations
|
|
}
|
|
|
|
if (slowComponents.some(component => component.lines >= THRESHOLDS.veryLargeComponentLines)) {
|
|
recommendations.push('Split components over 300 lines into smaller pieces to reduce render work.')
|
|
}
|
|
|
|
if (slowComponents.some(component => component.effects >= THRESHOLDS.highEffectCount)) {
|
|
recommendations.push('Reduce the number of effects per component by extracting side effects into hooks.')
|
|
}
|
|
|
|
if (slowComponents.some(component => component.hooks.total >= THRESHOLDS.highHookCount)) {
|
|
recommendations.push('Consider splitting stateful logic across smaller components or hooks.')
|
|
}
|
|
|
|
if (slowComponents.some(component => component.memoization === 0 && component.estimatedRenderTimeMs >= THRESHOLDS.slowRenderMs)) {
|
|
recommendations.push('Add memoization (React.memo/useMemo/useCallback) where render work is heavy.')
|
|
}
|
|
|
|
if (recommendations.length === 0) {
|
|
recommendations.push('Review flagged components for unnecessary renders or expensive computations.')
|
|
}
|
|
|
|
return recommendations
|
|
}
|
|
|
|
const rootDir = pickSourceRoot()
|
|
|
|
if (!rootDir) {
|
|
console.log(JSON.stringify({
|
|
analysisType: 'static-heuristic',
|
|
averageRenderTime: 0,
|
|
slowComponents: [],
|
|
recommendations: ['No source directory found to analyze.'],
|
|
timestamp: new Date().toISOString(),
|
|
}, null, 2))
|
|
process.exit(0)
|
|
}
|
|
|
|
const files: string[] = []
|
|
walkDir(rootDir, files)
|
|
|
|
const metrics: ComponentMetrics[] = files
|
|
.map(file => analyzeFile(file))
|
|
.filter((result): result is ComponentMetrics => result !== null)
|
|
|
|
const averageRenderTime = metrics.length === 0
|
|
? 0
|
|
: Math.round((metrics.reduce((sum, metric) => sum + metric.estimatedRenderTimeMs, 0) / metrics.length) * 10) / 10
|
|
|
|
const slowComponents = metrics
|
|
.filter(metric => metric.reasons.length > 0 || metric.estimatedRenderTimeMs >= THRESHOLDS.slowRenderMs)
|
|
.sort((a, b) => b.estimatedRenderTimeMs - a.estimatedRenderTimeMs)
|
|
|
|
const topByLines = [...metrics].sort((a, b) => b.lines - a.lines).slice(0, 10)
|
|
const topByHooks = [...metrics].sort((a, b) => b.hooks.total - a.hooks.total).slice(0, 10)
|
|
|
|
const summary = {
|
|
analysisType: 'static-heuristic',
|
|
rootDir: relative(process.cwd(), rootDir) || '.',
|
|
componentsAnalyzed: metrics.length,
|
|
averageRenderTime,
|
|
averageRenderTimeMs: averageRenderTime,
|
|
slowComponentsTotal: slowComponents.length,
|
|
thresholds: THRESHOLDS,
|
|
slowComponents: slowComponents.slice(0, 15),
|
|
topByLines,
|
|
topByHooks,
|
|
recommendations: buildRecommendations(slowComponents),
|
|
note: 'Estimated render times are derived from file size and hook usage. Use React Profiler for real timings.',
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
|
|
console.log(JSON.stringify(summary, null, 2))
|