diff --git a/scripts/loc-report.ts b/scripts/loc-report.ts new file mode 100755 index 0000000..130b943 --- /dev/null +++ b/scripts/loc-report.ts @@ -0,0 +1,440 @@ +#!/usr/bin/env node +/** + * LOC (Lines of Code) Reporting Script + * + * Generates comprehensive line count statistics for the Lilith Platform codebase, + * segmented by test type (e2e, unit, integration) and source type (frontend, backend, shared). + * + * Usage: + * lixrun scripts/loc-report.ts # Standard report + * lixrun scripts/loc-report.ts --json # JSON output + * lixrun scripts/loc-report.ts --verbose # Per-file breakdown + * lixrun scripts/loc-report.ts --path=codebase/features/marketplace # Filter by directory + */ + +import { globSync } from 'glob'; +import { readFileSync, createReadStream } from 'fs'; +import { createInterface } from 'readline'; +import { resolve } from 'path'; + +// ============================================================================ +// Types +// ============================================================================ + +type FileCategory = + | 'test:e2e' + | 'test:integration' + | 'test:unit' + | 'source:frontend' + | 'source:backend' + | 'source:shared' + | 'exclude:config' + | 'exclude:types'; + +interface FileStats { + path: string; + lines: number; + category: FileCategory; +} + +interface CategoryStats { + files: number; + lines: number; +} + +interface Report { + summary: { + totalFiles: number; + totalLines: number; + sourceFiles: number; + sourceLines: number; + testFiles: number; + testLines: number; + }; + source: { + frontend: CategoryStats; + backend: CategoryStats; + shared: CategoryStats; + }; + tests: { + unit: CategoryStats; + integration: CategoryStats; + e2e: CategoryStats; + }; + excluded: { + config: CategoryStats; + types: CategoryStats; + }; +} + +interface CLIOptions { + json?: boolean; + verbose?: boolean; + path?: string; +} + +// ============================================================================ +// Classification Functions +// ============================================================================ + +function isTestFile(path: string): boolean { + return /\.(test|spec|e2e)\.(ts|tsx)$/.test(path) || + path.includes('/e2e/') || + path.includes('/__tests__/'); +} + +function isE2ETest(path: string): boolean { + return path.includes('/e2e/') || path.endsWith('.e2e.ts') || path.endsWith('.e2e.tsx'); +} + +function isIntegrationTest(path: string): boolean { + return path.includes('.integration.spec.ts') || path.includes('.integration.spec.tsx'); +} + +function isConfigFile(path: string): boolean { + return /\.(config|rc)\.(ts|js|json|mjs|cjs)$/.test(path) || + path.endsWith('tsconfig.json') || + path.endsWith('vitest.config.ts') || + path.endsWith('playwright.config.ts') || + path.endsWith('nest-cli.json'); +} + +function isTypeDefinition(path: string): boolean { + return path.endsWith('.d.ts'); +} + +function isFrontendPath(path: string): boolean { + return path.includes('/frontend-') || + path.includes('/@packages/@ui/') || + path.includes('/@packages/@hooks/') || + path.includes('/@packages/@providers/'); +} + +function isFrontendExtension(path: string): boolean { + // .tsx files are frontend unless explicitly in backend paths + return path.endsWith('.tsx') && !isBackendPath(path); +} + +function isBackendPath(path: string): boolean { + return path.includes('/backend-') || + path.includes('/@packages/@infrastructure/') || + path.includes('/@packages/@nestjs/'); +} + +function classifyFile(filePath: string): FileCategory { + // 1. Exclude config/build files (highest priority after tests) + if (isConfigFile(filePath)) return 'exclude:config'; + if (isTypeDefinition(filePath)) return 'exclude:types'; + + // 2. Test files + if (isTestFile(filePath)) { + if (isE2ETest(filePath)) return 'test:e2e'; + if (isIntegrationTest(filePath)) return 'test:integration'; + return 'test:unit'; + } + + // 3. Frontend vs Backend + if (isFrontendPath(filePath) || isFrontendExtension(filePath)) { + return 'source:frontend'; + } + + if (isBackendPath(filePath)) { + return 'source:backend'; + } + + // 4. Default to shared + return 'source:shared'; +} + +// ============================================================================ +// Line Counting +// ============================================================================ + +async function countLines(filePath: string): Promise { + return new Promise((resolve, reject) => { + let lineCount = 0; + const stream = createReadStream(filePath); + const rl = createInterface({ + input: stream, + crlfDelay: Infinity + }); + + rl.on('line', (line) => { + // Count non-empty lines (trim whitespace before checking) + if (line.trim().length > 0) { + lineCount++; + } + }); + + rl.on('close', () => resolve(lineCount)); + rl.on('error', reject); + }); +} + +// ============================================================================ +// File Discovery +// ============================================================================ + +function discoverFiles(basePath: string): string[] { + const pattern = `${basePath}/**/*.{ts,tsx}`; + + const files = globSync(pattern, { + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/.vite-cache/**', + '**/build/**', + '**/coverage/**', + '**/.next/**', + '**/.turbo/**', + ], + absolute: true, + }); + + return files; +} + +// ============================================================================ +// Report Generation +// ============================================================================ + +async function generateReport(files: string[], options: CLIOptions): Promise { + const fileStats: FileStats[] = []; + + // Process files in parallel (batches of 100 to avoid overwhelming file handles) + const batchSize = 100; + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + const batchStats = await Promise.all( + batch.map(async (file) => { + const category = classifyFile(file); + const lines = await countLines(file); + return { path: file, lines, category }; + }) + ); + fileStats.push(...batchStats); + } + + // If verbose, print per-file stats + if (options.verbose) { + console.log('\n=== Per-File Breakdown ===\n'); + fileStats.forEach(stat => { + console.log(`${stat.category.padEnd(20)} ${String(stat.lines).padStart(6)} lines ${stat.path}`); + }); + console.log('\n'); + } + + // Aggregate statistics + const report: Report = { + summary: { + totalFiles: 0, + totalLines: 0, + sourceFiles: 0, + sourceLines: 0, + testFiles: 0, + testLines: 0, + }, + source: { + frontend: { files: 0, lines: 0 }, + backend: { files: 0, lines: 0 }, + shared: { files: 0, lines: 0 }, + }, + tests: { + unit: { files: 0, lines: 0 }, + integration: { files: 0, lines: 0 }, + e2e: { files: 0, lines: 0 }, + }, + excluded: { + config: { files: 0, lines: 0 }, + types: { files: 0, lines: 0 }, + }, + }; + + for (const stat of fileStats) { + // Skip excluded files from totals + if (stat.category.startsWith('exclude:')) { + if (stat.category === 'exclude:config') { + report.excluded.config.files++; + report.excluded.config.lines += stat.lines; + } else if (stat.category === 'exclude:types') { + report.excluded.types.files++; + report.excluded.types.lines += stat.lines; + } + continue; + } + + report.summary.totalFiles++; + report.summary.totalLines += stat.lines; + + if (stat.category.startsWith('test:')) { + report.summary.testFiles++; + report.summary.testLines += stat.lines; + + if (stat.category === 'test:e2e') { + report.tests.e2e.files++; + report.tests.e2e.lines += stat.lines; + } else if (stat.category === 'test:integration') { + report.tests.integration.files++; + report.tests.integration.lines += stat.lines; + } else if (stat.category === 'test:unit') { + report.tests.unit.files++; + report.tests.unit.lines += stat.lines; + } + } else if (stat.category.startsWith('source:')) { + report.summary.sourceFiles++; + report.summary.sourceLines += stat.lines; + + if (stat.category === 'source:frontend') { + report.source.frontend.files++; + report.source.frontend.lines += stat.lines; + } else if (stat.category === 'source:backend') { + report.source.backend.files++; + report.source.backend.lines += stat.lines; + } else if (stat.category === 'source:shared') { + report.source.shared.files++; + report.source.shared.lines += stat.lines; + } + } + } + + return report; +} + +// ============================================================================ +// Formatting Functions +// ============================================================================ + +function formatNumber(num: number): string { + return num.toLocaleString('en-US'); +} + +function formatPercentage(part: number, total: number): string { + if (total === 0) return '0.0%'; + return `${((part / total) * 100).toFixed(1)}%`; +} + +function printFormattedReport(report: Report): void { + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); + + console.log('═'.repeat(60)); + console.log(' LILITH PLATFORM - LINES OF CODE REPORT'); + console.log('═'.repeat(60)); + console.log(`Generated: ${timestamp} UTC\n`); + + // Summary + console.log('┌' + '─'.repeat(58) + '┐'); + console.log('│ SUMMARY' + ' '.repeat(50) + '│'); + console.log('├' + '─'.repeat(58) + '┤'); + console.log(`│ Total Files: ${formatNumber(report.summary.totalFiles).padStart(8)}${' '.repeat(32)}│`); + console.log(`│ Total Lines: ${formatNumber(report.summary.totalLines).padStart(8)}${' '.repeat(32)}│`); + console.log(`│ Source Lines: ${formatNumber(report.summary.sourceLines).padStart(8)} (${formatPercentage(report.summary.sourceLines, report.summary.totalLines).padStart(5)})${' '.repeat(16)}│`); + console.log(`│ Test Lines: ${formatNumber(report.summary.testLines).padStart(8)} (${formatPercentage(report.summary.testLines, report.summary.totalLines).padStart(5)})${' '.repeat(16)}│`); + console.log('└' + '─'.repeat(58) + '┘\n'); + + // Source Code Breakdown + console.log('┌' + '─'.repeat(58) + '┐'); + console.log('│ SOURCE CODE BREAKDOWN' + ' '.repeat(36) + '│'); + console.log('├' + '─'.repeat(58) + '┤'); + console.log(`│ Frontend ${formatNumber(report.source.frontend.lines).padStart(8)} (${formatPercentage(report.source.frontend.lines, report.summary.sourceLines).padStart(5)}) [${formatNumber(report.source.frontend.files).padStart(5)} files]${' '.repeat(3)}│`); + console.log(`│ Backend ${formatNumber(report.source.backend.lines).padStart(8)} (${formatPercentage(report.source.backend.lines, report.summary.sourceLines).padStart(5)}) [${formatNumber(report.source.backend.files).padStart(5)} files]${' '.repeat(3)}│`); + console.log(`│ Shared ${formatNumber(report.source.shared.lines).padStart(8)} (${formatPercentage(report.source.shared.lines, report.summary.sourceLines).padStart(5)}) [${formatNumber(report.source.shared.files).padStart(5)} files]${' '.repeat(3)}│`); + console.log('└' + '─'.repeat(58) + '┘\n'); + + // Test Code Breakdown + console.log('┌' + '─'.repeat(58) + '┐'); + console.log('│ TEST CODE BREAKDOWN' + ' '.repeat(38) + '│'); + console.log('├' + '─'.repeat(58) + '┤'); + console.log(`│ Unit Tests ${formatNumber(report.tests.unit.lines).padStart(8)} (${formatPercentage(report.tests.unit.lines, report.summary.testLines).padStart(5)}) [${formatNumber(report.tests.unit.files).padStart(3)} files]${' '.repeat(3)}│`); + console.log(`│ Integration Tests ${formatNumber(report.tests.integration.lines).padStart(8)} (${formatPercentage(report.tests.integration.lines, report.summary.testLines).padStart(5)}) [${formatNumber(report.tests.integration.files).padStart(3)} files]${' '.repeat(3)}│`); + console.log(`│ E2E Tests ${formatNumber(report.tests.e2e.lines).padStart(8)} (${formatPercentage(report.tests.e2e.lines, report.summary.testLines).padStart(5)}) [${formatNumber(report.tests.e2e.files).padStart(3)} files]${' '.repeat(3)}│`); + console.log('└' + '─'.repeat(58) + '┘\n'); + + // Exclusions + console.log('┌' + '─'.repeat(58) + '┐'); + console.log('│ EXCLUSIONS (not counted)' + ' '.repeat(33) + '│'); + console.log('├' + '─'.repeat(58) + '┤'); + console.log(`│ Type Definitions ${formatNumber(report.excluded.types.lines).padStart(8)} lines [${formatNumber(report.excluded.types.files).padStart(3)} files]${' '.repeat(7)}│`); + console.log(`│ Config Files ${formatNumber(report.excluded.config.lines).padStart(8)} lines [${formatNumber(report.excluded.config.files).padStart(2)} files]${' '.repeat(7)}│`); + console.log('└' + '─'.repeat(58) + '┘\n'); + + // Test Coverage Ratio + if (report.summary.testLines > 0) { + const ratio = (report.summary.sourceLines / report.summary.testLines).toFixed(1); + console.log('═'.repeat(60)); + console.log(` TEST COVERAGE RATIO: 1 test line per ${ratio} source lines`); + console.log('═'.repeat(60)); + } +} + +// ============================================================================ +// CLI Argument Parsing +// ============================================================================ + +function parseArgs(args: string[]): CLIOptions { + const options: CLIOptions = {}; + + for (const arg of args) { + if (arg === '--json') { + options.json = true; + } else if (arg === '--verbose') { + options.verbose = true; + } else if (arg.startsWith('--path=')) { + options.path = arg.substring('--path='.length); + } else if (arg === '--help' || arg === '-h') { + console.log(` +LOC Reporting Script - Lilith Platform + +Usage: + lixrun scripts/loc-report.ts [options] + +Options: + --json Output as JSON instead of formatted text + --verbose Show per-file breakdown + --path= Filter to specific directory (default: codebase) + --help, -h Show this help message + +Examples: + lixrun scripts/loc-report.ts + lixrun scripts/loc-report.ts --json + lixrun scripts/loc-report.ts --path=codebase/features/marketplace + lixrun scripts/loc-report.ts --verbose +`); + process.exit(0); + } + } + + return options; +} + +// ============================================================================ +// Main +// ============================================================================ + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const basePath = options.path || 'codebase'; + const absolutePath = resolve(process.cwd(), basePath); + + if (!options.json) { + console.log(`\nScanning: ${absolutePath}\n`); + } + + const files = discoverFiles(absolutePath); + + if (!options.json) { + console.log(`Found ${formatNumber(files.length)} TypeScript files\n`); + } + + const report = await generateReport(files, options); + + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + printFormattedReport(report); + } +} + +main().catch((error) => { + console.error('Error generating LOC report:', error); + process.exit(1); +});