diff --git a/docs/todo/LAMBDA_REFACTOR_PROGRESS.md b/docs/todo/LAMBDA_REFACTOR_PROGRESS.md index c806bd2d8..e38cf5342 100644 --- a/docs/todo/LAMBDA_REFACTOR_PROGRESS.md +++ b/docs/todo/LAMBDA_REFACTOR_PROGRESS.md @@ -5,9 +5,9 @@ ## Summary - **Total files > 150 lines:** 106 -- **Pending:** 93 +- **Pending:** 91 - **In Progress:** 0 -- **Completed:** 1 +- **Completed:** 3 - **Skipped:** 12 ## By Category @@ -38,8 +38,8 @@ Library and tool files - easiest to refactor - [ ] `frontends/nextjs/src/lib/components/component-catalog.ts` (337 lines) - [ ] `frontends/nextjs/src/lib/schema/default-schema.ts` (308 lines) - [ ] `frontends/nextjs/src/lib/lua/snippets/lua-snippets-data.ts` (983 lines) -- [ ] `tools/analysis/code/analyze-render-performance.ts` (294 lines) -- [ ] `tools/misc/metrics/enforce-size-limits.ts` (249 lines) +- [x] `tools/analysis/code/analyze-render-performance.ts` (294 lines) +- [x] `tools/misc/metrics/enforce-size-limits.ts` (249 lines) - [ ] `tools/refactoring/refactor-to-lambda.ts` (243 lines) - [x] `tools/analysis/test/analyze-implementation-completeness.ts` (230 lines) - [ ] `tools/detection/detect-stub-implementations.ts` (215 lines) diff --git a/tools/analysis/code/analyze-render-performance.ts b/tools/analysis/code/analyze-render-performance.ts index f9a549643..692b72f13 100644 --- a/tools/analysis/code/analyze-render-performance.ts +++ b/tools/analysis/code/analyze-render-performance.ts @@ -1,294 +1,5 @@ #!/usr/bin/env tsx -import { existsSync, readdirSync, readFileSync, statSync } from 'fs' -import { basename, extname, join, relative } from 'path' +import { runRenderPerformanceAnalysis } from './analyze-render-performance' -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)) +console.log(JSON.stringify(runRenderPerformanceAnalysis(), null, 2)) diff --git a/tools/analysis/code/analyze-render-performance/constants.ts b/tools/analysis/code/analyze-render-performance/constants.ts new file mode 100644 index 000000000..7dfb56029 --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/constants.ts @@ -0,0 +1,39 @@ +export const BUILTIN_HOOKS = [ + 'useState', + 'useReducer', + 'useEffect', + 'useLayoutEffect', + 'useInsertionEffect', + 'useMemo', + 'useCallback', + 'useRef', + 'useContext', + 'useSyncExternalStore', + 'useTransition', + 'useDeferredValue', + 'useId', + 'useImperativeHandle', +] + +export const BUILTIN_HOOK_SET = new Set(BUILTIN_HOOKS) +export const SKIP_DIRS = new Set([ + 'node_modules', + '.next', + 'dist', + 'build', + 'coverage', + '.git', + '__tests__', + '__mocks__', + '__snapshots__', +]) + +export const THRESHOLDS = { + slowRenderMs: 16, + largeComponentLines: 200, + veryLargeComponentLines: 300, + highHookCount: 12, + highEffectCount: 3, +} + +export const TARGET_EXTENSIONS = new Set(['.tsx']) diff --git a/tools/analysis/code/analyze-render-performance/functions/analyze-file.ts b/tools/analysis/code/analyze-render-performance/functions/analyze-file.ts new file mode 100644 index 000000000..ad34bf9b1 --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/functions/analyze-file.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'fs' +import { basename, relative } from 'path' +import { BUILTIN_HOOKS, BUILTIN_HOOK_SET, THRESHOLDS } from '../constants' +import { ComponentMetrics, HookCounts } from '../types' +import { countMatches } from './count-matches' +import { estimateRenderTimeMs } from './estimate-render-time-ms' + +export 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, + } +} diff --git a/tools/analysis/code/analyze-render-performance/functions/build-recommendations.ts b/tools/analysis/code/analyze-render-performance/functions/build-recommendations.ts new file mode 100644 index 000000000..422c0d772 --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/functions/build-recommendations.ts @@ -0,0 +1,33 @@ +import { THRESHOLDS } from '../constants' +import { ComponentMetrics } from '../types' + +export 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 +} diff --git a/tools/analysis/code/analyze-render-performance/functions/build-summary.ts b/tools/analysis/code/analyze-render-performance/functions/build-summary.ts new file mode 100644 index 000000000..bda3f85a8 --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/functions/build-summary.ts @@ -0,0 +1,33 @@ +import { relative } from 'path' +import { THRESHOLDS } from '../constants' +import { ComponentMetrics, RenderPerformanceSummary } from '../types' +import { buildRecommendations } from './build-recommendations' + +export function buildSummary(metrics: ComponentMetrics[], rootDir: string): RenderPerformanceSummary { + 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) + + return { + 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(), + } +} diff --git a/tools/analysis/code/analyze-render-performance/functions/count-matches.ts b/tools/analysis/code/analyze-render-performance/functions/count-matches.ts new file mode 100644 index 000000000..923a50bfa --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/functions/count-matches.ts @@ -0,0 +1,3 @@ +export function countMatches(content: string, regex: RegExp): number { + return content.match(regex)?.length ?? 0 +} diff --git a/tools/analysis/code/analyze-render-performance/functions/estimate-render-time-ms.ts b/tools/analysis/code/analyze-render-performance/functions/estimate-render-time-ms.ts new file mode 100644 index 000000000..84bd1a5af --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/functions/estimate-render-time-ms.ts @@ -0,0 +1,9 @@ +export 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) +} diff --git a/tools/analysis/code/analyze-render-performance/functions/pick-source-root.ts b/tools/analysis/code/analyze-render-performance/functions/pick-source-root.ts new file mode 100644 index 000000000..c0a9e3f3b --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/functions/pick-source-root.ts @@ -0,0 +1,18 @@ +import { existsSync } from 'fs' +import { join } from 'path' + +export 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 +} diff --git a/tools/analysis/code/analyze-render-performance/functions/walk-dir.ts b/tools/analysis/code/analyze-render-performance/functions/walk-dir.ts new file mode 100644 index 000000000..3c312940e --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/functions/walk-dir.ts @@ -0,0 +1,44 @@ +import { readdirSync, statSync } from 'fs' +import { extname, join } from 'path' +import { SKIP_DIRS, TARGET_EXTENSIONS } from '../constants' + +export 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) + } +} diff --git a/tools/analysis/code/analyze-render-performance/index.ts b/tools/analysis/code/analyze-render-performance/index.ts new file mode 100644 index 000000000..ecfeaf1d4 --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/index.ts @@ -0,0 +1,36 @@ +import { THRESHOLDS } from './constants' +import { analyzeFile } from './functions/analyze-file' +import { buildSummary } from './functions/build-summary' +import { pickSourceRoot } from './functions/pick-source-root' +import { walkDir } from './functions/walk-dir' +import { RenderPerformanceSummary } from './types' + +export function runRenderPerformanceAnalysis(): RenderPerformanceSummary { + const rootDir = pickSourceRoot() + + if (!rootDir) { + return { + analysisType: 'static-heuristic', + componentsAnalyzed: 0, + averageRenderTime: 0, + averageRenderTimeMs: 0, + slowComponentsTotal: 0, + thresholds: THRESHOLDS, + slowComponents: [], + topByLines: [], + topByHooks: [], + recommendations: ['No source directory found to analyze.'], + note: 'Estimated render times are derived from file size and hook usage. Use React Profiler for real timings.', + timestamp: new Date().toISOString(), + } + } + + const files: string[] = [] + walkDir(rootDir, files) + + const metrics = files + .map(file => analyzeFile(file)) + .filter((result): result is NonNullable> => result !== null) + + return buildSummary(metrics, rootDir) +} diff --git a/tools/analysis/code/analyze-render-performance/types.ts b/tools/analysis/code/analyze-render-performance/types.ts new file mode 100644 index 000000000..4db522cc8 --- /dev/null +++ b/tools/analysis/code/analyze-render-performance/types.ts @@ -0,0 +1,43 @@ +export interface HookCounts { + [key: string]: number +} + +export 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' +} + +export interface RenderPerformanceSummary { + analysisType: string + rootDir?: string + componentsAnalyzed: number + averageRenderTime: number + averageRenderTimeMs: number + slowComponentsTotal: number + thresholds: { + slowRenderMs: number + largeComponentLines: number + veryLargeComponentLines: number + highHookCount: number + highEffectCount: number + } + slowComponents: ComponentMetrics[] + topByLines: ComponentMetrics[] + topByHooks: ComponentMetrics[] + recommendations: string[] + note: string + timestamp: string +} diff --git a/tools/misc/metrics/enforce-size-limits.ts b/tools/misc/metrics/enforce-size-limits.ts index 2bea79ed1..01cec8f8c 100644 --- a/tools/misc/metrics/enforce-size-limits.ts +++ b/tools/misc/metrics/enforce-size-limits.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Code Size Limit Enforcer - * + * * Enforces multiple metrics to keep files maintainable: * - TypeScript/React: Max 150 lines of actual code (LOC) * - Any file: Max 300 lines total (including comments/whitespace) @@ -10,240 +10,12 @@ * - Max 5 parameters per function */ -import * as fs from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; +import { runSizeLimitEnforcement } from './enforce-size-limits' -interface FileSizeLimits { - maxLoc: number; - maxTotalLines: number; - maxNestingDepth: number; - maxExports: number; - maxFunctionParams: number; +console.log('šŸ” Scanning for size limit violations...\n') + +const { exitCode } = runSizeLimitEnforcement() + +if (exitCode !== 0) { + process.exit(exitCode) } - -const DEFAULT_LIMITS: Record = { - tsx: { maxLoc: 150, maxTotalLines: 200, maxNestingDepth: 3, maxExports: 5, maxFunctionParams: 5 }, - ts: { maxLoc: 150, maxTotalLines: 200, maxNestingDepth: 3, maxExports: 10, maxFunctionParams: 5 }, - jsx: { maxLoc: 150, maxTotalLines: 200, maxNestingDepth: 3, maxExports: 5, maxFunctionParams: 5 }, - js: { maxLoc: 150, maxTotalLines: 200, maxNestingDepth: 3, maxExports: 10, maxFunctionParams: 5 }, -}; - -interface Violation { - file: string; - metric: string; - current: number; - limit: number; - severity: 'error' | 'warning'; -} - -const violations: Violation[] = []; - -function countLinesOfCode(content: string): number { - return content - .split('\n') - .filter(line => { - const trimmed = line.trim(); - return trimmed.length > 0 && !trimmed.startsWith('//'); - }) - .length; -} - -function countExports(content: string): number { - const exportMatches = content.match(/^\s*(export\s+(default\s+)?(function|const|class|interface|type|enum))/gm); - return exportMatches ? exportMatches.length : 0; -} - -function maxNestingDepth(content: string): number { - let maxDepth = 0; - let currentDepth = 0; - - for (const char of content) { - if (char === '{' || char === '[' || char === '(') { - currentDepth++; - maxDepth = Math.max(maxDepth, currentDepth); - } else if (char === '}' || char === ']' || char === ')') { - currentDepth--; - } - } - - return maxDepth; -} - -function maxFunctionParams(content: string): number { - const funcMatches = content.match(/(?:function|const\s+\w+\s*=|\s*\()\s*\(([^)]*)\)/g); - if (!funcMatches) return 0; - - let maxParams = 0; - for (const match of funcMatches) { - const params = match - .substring(match.indexOf('(') + 1, match.lastIndexOf(')')) - .split(',') - .filter(p => p.trim().length > 0).length; - maxParams = Math.max(maxParams, params); - } - - return maxParams; -} - -function analyzeFile(filePath: string): void { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const ext = path.extname(filePath).substring(1); - - if (!DEFAULT_LIMITS[ext]) return; - - const limits = DEFAULT_LIMITS[ext]; - const totalLines = content.split('\n').length; - const loc = countLinesOfCode(content); - const exports = countExports(content); - const nesting = maxNestingDepth(content); - const params = maxFunctionParams(content); - - if (loc > limits.maxLoc) { - violations.push({ - file: filePath, - metric: `Lines of Code (LOC)`, - current: loc, - limit: limits.maxLoc, - severity: 'error', - }); - } - - if (totalLines > limits.maxTotalLines) { - violations.push({ - file: filePath, - metric: `Total Lines`, - current: totalLines, - limit: limits.maxTotalLines, - severity: 'warning', - }); - } - - if (exports > limits.maxExports) { - violations.push({ - file: filePath, - metric: `Number of Exports`, - current: exports, - limit: limits.maxExports, - severity: 'warning', - }); - } - - if (nesting > limits.maxNestingDepth) { - violations.push({ - file: filePath, - metric: `Max Nesting Depth`, - current: nesting, - limit: limits.maxNestingDepth, - severity: 'warning', - }); - } - - if (params > limits.maxFunctionParams) { - violations.push({ - file: filePath, - metric: `Max Function Parameters`, - current: params, - limit: limits.maxFunctionParams, - severity: 'warning', - }); - } - } catch (error) { - // Silently skip files that can't be read - } -} - -function scanDirectory(dir: string, exclude: string[] = []): void { - const files = fs.readdirSync(dir); - - for (const file of files) { - const fullPath = path.join(dir, file); - const stat = fs.statSync(fullPath); - - // Skip excluded directories - if (stat.isDirectory()) { - if (exclude.some(ex => fullPath.includes(ex))) { - continue; - } - scanDirectory(fullPath, exclude); - } else if (/\.(ts|tsx|js|jsx)$/.test(file)) { - analyzeFile(fullPath); - } - } -} - -function generateReport(): void { - if (violations.length === 0) { - console.log('āœ… All files comply with size limits!'); - return; - } - - const errors = violations.filter(v => v.severity === 'error'); - const warnings = violations.filter(v => v.severity === 'warning'); - - console.log('\nšŸ“Š Code Size Limit Violations Report\n'); - console.log('━'.repeat(100)); - - if (errors.length > 0) { - console.log(`\nāŒ ERRORS (${errors.length}):\n`); - for (const v of errors) { - console.log(` šŸ“„ ${v.file}`); - console.log(` ${v.metric}: ${v.current} / ${v.limit}`); - console.log(''); - } - } - - if (warnings.length > 0) { - console.log(`\nāš ļø WARNINGS (${warnings.length}):\n`); - for (const v of warnings) { - console.log(` šŸ“„ ${v.file}`); - console.log(` ${v.metric}: ${v.current} / ${v.limit}`); - console.log(''); - } - } - - console.log('━'.repeat(100)); - console.log( - `\nšŸ“ˆ Summary: ${errors.length} errors, ${warnings.length} warnings\n` - ); - - // Export to JSON for CI/CD - const report = { - timestamp: new Date().toISOString(), - errors: errors.length, - warnings: warnings.length, - violations: violations.map(v => ({ - file: v.file, - metric: v.metric, - current: v.current, - limit: v.limit, - severity: v.severity, - })), - }; - - fs.writeFileSync( - path.join(process.cwd(), 'size-limits-report.json'), - JSON.stringify(report, null, 2) - ); - - if (errors.length > 0) { - process.exit(1); - } -} - -// Main execution -const rootDir = process.cwd(); -const excludeDirs = [ - 'node_modules', - 'build', - '.next', - 'dist', - '.git', - 'coverage', - '.venv', -]; - -console.log('šŸ” Scanning for size limit violations...\n'); -scanDirectory(rootDir, excludeDirs); -generateReport(); diff --git a/tools/misc/metrics/enforce-size-limits/constants.ts b/tools/misc/metrics/enforce-size-limits/constants.ts new file mode 100644 index 000000000..39b4071dc --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/constants.ts @@ -0,0 +1,15 @@ +import { EnforcementConfig } from './types' + +const DEFAULT_LIMITS = { + tsx: { maxLoc: 150, maxTotalLines: 200, maxNestingDepth: 3, maxExports: 5, maxFunctionParams: 5 }, + ts: { maxLoc: 150, maxTotalLines: 200, maxNestingDepth: 3, maxExports: 10, maxFunctionParams: 5 }, + jsx: { maxLoc: 150, maxTotalLines: 200, maxNestingDepth: 3, maxExports: 5, maxFunctionParams: 5 }, + js: { maxLoc: 150, maxTotalLines: 200, maxNestingDepth: 3, maxExports: 10, maxFunctionParams: 5 }, +} + +export const DEFAULT_CONFIG: EnforcementConfig = { + rootDir: process.cwd(), + excludeDirs: ['node_modules', 'build', '.next', 'dist', '.git', 'coverage', '.venv'], + reportFileName: 'size-limits-report.json', + limits: DEFAULT_LIMITS, +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/analyze-file.ts b/tools/misc/metrics/enforce-size-limits/functions/analyze-file.ts new file mode 100644 index 000000000..82897d708 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/analyze-file.ts @@ -0,0 +1,79 @@ +import fs from 'fs' +import path from 'path' +import { FileSizeLimits, Violation } from '../types' +import { countExports } from './count-exports' +import { countLinesOfCode } from './count-lines-of-code' +import { maxFunctionParams } from './max-function-params' +import { maxNestingDepth } from './max-nesting-depth' + +export function analyzeFile(filePath: string, limits: Record): Violation[] { + const violations: Violation[] = [] + + try { + const content = fs.readFileSync(filePath, 'utf-8') + const ext = path.extname(filePath).substring(1) + + if (!limits[ext]) return violations + + const fileLimits = limits[ext] + const totalLines = content.split('\n').length + const loc = countLinesOfCode(content) + const exports = countExports(content) + const nesting = maxNestingDepth(content) + const params = maxFunctionParams(content) + + if (loc > fileLimits.maxLoc) { + violations.push({ + file: filePath, + metric: 'Lines of Code (LOC)', + current: loc, + limit: fileLimits.maxLoc, + severity: 'error', + }) + } + + if (totalLines > fileLimits.maxTotalLines) { + violations.push({ + file: filePath, + metric: 'Total Lines', + current: totalLines, + limit: fileLimits.maxTotalLines, + severity: 'warning', + }) + } + + if (exports > fileLimits.maxExports) { + violations.push({ + file: filePath, + metric: 'Number of Exports', + current: exports, + limit: fileLimits.maxExports, + severity: 'warning', + }) + } + + if (nesting > fileLimits.maxNestingDepth) { + violations.push({ + file: filePath, + metric: 'Max Nesting Depth', + current: nesting, + limit: fileLimits.maxNestingDepth, + severity: 'warning', + }) + } + + if (params > fileLimits.maxFunctionParams) { + violations.push({ + file: filePath, + metric: 'Max Function Parameters', + current: params, + limit: fileLimits.maxFunctionParams, + severity: 'warning', + }) + } + } catch { + // Silently skip files that can't be read + } + + return violations +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/build-report-data.ts b/tools/misc/metrics/enforce-size-limits/functions/build-report-data.ts new file mode 100644 index 000000000..aa6a6c96e --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/build-report-data.ts @@ -0,0 +1,13 @@ +import { ReportData, Violation } from '../types' + +export function buildReportData(violations: Violation[]): ReportData { + const errors = violations.filter(v => v.severity === 'error').length + const warnings = violations.filter(v => v.severity === 'warning').length + + return { + timestamp: new Date().toISOString(), + errors, + warnings, + violations, + } +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/count-exports.ts b/tools/misc/metrics/enforce-size-limits/functions/count-exports.ts new file mode 100644 index 000000000..72f09e139 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/count-exports.ts @@ -0,0 +1,4 @@ +export function countExports(content: string): number { + const exportMatches = content.match(/^\s*(export\s+(default\s+)?(function|const|class|interface|type|enum))/gm) + return exportMatches ? exportMatches.length : 0 +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/count-lines-of-code.ts b/tools/misc/metrics/enforce-size-limits/functions/count-lines-of-code.ts new file mode 100644 index 000000000..ce4f23c65 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/count-lines-of-code.ts @@ -0,0 +1,9 @@ +export function countLinesOfCode(content: string): number { + return content + .split('\n') + .filter(line => { + const trimmed = line.trim() + return trimmed.length > 0 && !trimmed.startsWith('//') + }) + .length +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/max-function-params.ts b/tools/misc/metrics/enforce-size-limits/functions/max-function-params.ts new file mode 100644 index 000000000..752290cc7 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/max-function-params.ts @@ -0,0 +1,15 @@ +export function maxFunctionParams(content: string): number { + const funcMatches = content.match(/(?:function|const\s+\w+\s*=|\s*\()\s*\(([^)]*)\)/g) + if (!funcMatches) return 0 + + let maxParams = 0 + for (const match of funcMatches) { + const params = match + .substring(match.indexOf('(') + 1, match.lastIndexOf(')')) + .split(',') + .filter(p => p.trim().length > 0).length + maxParams = Math.max(maxParams, params) + } + + return maxParams +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/max-nesting-depth.ts b/tools/misc/metrics/enforce-size-limits/functions/max-nesting-depth.ts new file mode 100644 index 000000000..49a123da1 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/max-nesting-depth.ts @@ -0,0 +1,15 @@ +export function maxNestingDepth(content: string): number { + let maxDepth = 0 + let currentDepth = 0 + + for (const char of content) { + if (char === '{' || char === '[' || char === '(') { + currentDepth++ + maxDepth = Math.max(maxDepth, currentDepth) + } else if (char === '}' || char === ']' || char === ')') { + currentDepth-- + } + } + + return maxDepth +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/print-report.ts b/tools/misc/metrics/enforce-size-limits/functions/print-report.ts new file mode 100644 index 000000000..bee1d6f02 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/print-report.ts @@ -0,0 +1,35 @@ +import { ReportData } from '../types' + +export function printReport(report: ReportData): void { + const errors = report.violations.filter(v => v.severity === 'error') + const warnings = report.violations.filter(v => v.severity === 'warning') + + if (report.violations.length === 0) { + console.log('āœ… All files comply with size limits!') + return + } + + console.log('\nšŸ“Š Code Size Limit Violations Report\n') + console.log('━'.repeat(100)) + + if (errors.length > 0) { + console.log(`\nāŒ ERRORS (${errors.length}):\n`) + for (const violation of errors) { + console.log(` šŸ“„ ${violation.file}`) + console.log(` ${violation.metric}: ${violation.current} / ${violation.limit}`) + console.log('') + } + } + + if (warnings.length > 0) { + console.log(`\nāš ļø WARNINGS (${warnings.length}):\n`) + for (const violation of warnings) { + console.log(` šŸ“„ ${violation.file}`) + console.log(` ${violation.metric}: ${violation.current} / ${violation.limit}`) + console.log('') + } + } + + console.log('━'.repeat(100)) + console.log(`\nšŸ“ˆ Summary: ${errors.length} errors, ${warnings.length} warnings\n`) +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/scan-directory.ts b/tools/misc/metrics/enforce-size-limits/functions/scan-directory.ts new file mode 100644 index 000000000..d0303465a --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/scan-directory.ts @@ -0,0 +1,25 @@ +import fs from 'fs' +import path from 'path' +import { FileSizeLimits, Violation } from '../types' +import { analyzeFile } from './analyze-file' + +export function scanDirectory(dir: string, limits: Record, exclude: string[] = []): Violation[] { + const violations: Violation[] = [] + const files = fs.readdirSync(dir) + + for (const file of files) { + const fullPath = path.join(dir, file) + const stat = fs.statSync(fullPath) + + if (stat.isDirectory()) { + if (exclude.some(ex => fullPath.includes(ex))) { + continue + } + violations.push(...scanDirectory(fullPath, limits, exclude)) + } else if (/\.(ts|tsx|js|jsx)$/.test(file)) { + violations.push(...analyzeFile(fullPath, limits)) + } + } + + return violations +} diff --git a/tools/misc/metrics/enforce-size-limits/functions/write-report-file.ts b/tools/misc/metrics/enforce-size-limits/functions/write-report-file.ts new file mode 100644 index 000000000..9ddff0c12 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/functions/write-report-file.ts @@ -0,0 +1,8 @@ +import fs from 'fs' +import path from 'path' +import { ReportData } from '../types' + +export function writeReportFile(report: ReportData, rootDir: string, fileName: string): void { + const destination = path.join(rootDir, fileName) + fs.writeFileSync(destination, JSON.stringify(report, null, 2)) +} diff --git a/tools/misc/metrics/enforce-size-limits/index.ts b/tools/misc/metrics/enforce-size-limits/index.ts new file mode 100644 index 000000000..13d978cf1 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/index.ts @@ -0,0 +1,17 @@ +import { DEFAULT_CONFIG } from './constants' +import { buildReportData } from './functions/build-report-data' +import { printReport } from './functions/print-report' +import { scanDirectory } from './functions/scan-directory' +import { writeReportFile } from './functions/write-report-file' +import { EnforcementConfig, ReportData } from './types' + +export function runSizeLimitEnforcement(config: EnforcementConfig = DEFAULT_CONFIG): { report: ReportData; exitCode: number } { + const violations = scanDirectory(config.rootDir, config.limits, config.excludeDirs) + const report = buildReportData(violations) + + printReport(report) + writeReportFile(report, config.rootDir, config.reportFileName) + + const exitCode = report.errors > 0 ? 1 : 0 + return { report, exitCode } +} diff --git a/tools/misc/metrics/enforce-size-limits/types.ts b/tools/misc/metrics/enforce-size-limits/types.ts new file mode 100644 index 000000000..55204fbb9 --- /dev/null +++ b/tools/misc/metrics/enforce-size-limits/types.ts @@ -0,0 +1,29 @@ +export interface FileSizeLimits { + maxLoc: number + maxTotalLines: number + maxNestingDepth: number + maxExports: number + maxFunctionParams: number +} + +export interface Violation { + file: string + metric: string + current: number + limit: number + severity: 'error' | 'warning' +} + +export interface EnforcementConfig { + rootDir: string + excludeDirs: string[] + reportFileName: string + limits: Record +} + +export interface ReportData { + timestamp: string + errors: number + warnings: number + violations: Violation[] +}