/** * PHP ingestion pipeline benchmark. * * Generates synthetic PHP codebases at increasing scales and measures * wall-clock time and peak heap through the full pipeline — parsing, * scope extraction, namespace-siblings (Steps 1-4), and call resolution. * * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/php-pipeline-benchmark.test.ts * * The benchmark uses workers (production path) by default. Set * a single-worker pool (the sequential parser was removed). */ import { describe, it, expect } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; interface BenchResult { fileCount: number; classCount: number; namespaceCount: number; elapsedMs: number; peakHeapMB: number; nodeCount: number; edgeCount: number; } function generatePhpFixture( fileCount: number, namespacesPerLevel: number, ): { dir: string; classCount: number; namespaceCount: number } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `php-bench-${fileCount}-`)); const namespaces: string[] = []; for (let i = 0; i < namespacesPerLevel; i++) { for (let j = 0; j < namespacesPerLevel; j++) { namespaces.push(`App\\Module${i}\\Sub${j}`); } } const classCount = fileCount; const namespaceCount = namespaces.length; for (let f = 0; f < fileCount; f++) { const ns = namespaces[f % namespaces.length]; const nsDir = ns.replace(/\\/g, '/'); const className = `Class${f}`; const targetDir = path.join(dir, nsDir); fs.mkdirSync(targetDir, { recursive: true }); const siblingIdx = (f + 1) % fileCount; const siblingClass = `Class${siblingIdx}`; const crossNsIdx = (f + Math.floor(fileCount / 3)) % fileCount; const crossNs = namespaces[crossNsIdx % namespaces.length]; const crossClass = `Class${crossNsIdx}`; const content = [ 'id;', ' }', '', ` public function process(): ${siblingClass}`, ' {', ` $sibling = new ${siblingClass}();`, ' return $sibling;', ' }', '', ns !== crossNs ? [ ` public function crossCall(): ${crossClass}`, ' {', ` $cross = new ${crossClass}();`, ` $cross->getId();`, ' return $cross;', ' }', ].join('\n') : '', '}', '', ] .filter(Boolean) .join('\n'); fs.writeFileSync(path.join(targetDir, `${className}.php`), content); } const composerJson = { name: 'bench/php-pipeline', autoload: { 'psr-4': { 'App\\': 'App/' } }, }; fs.writeFileSync(path.join(dir, 'composer.json'), JSON.stringify(composerJson, null, 2)); return { dir, classCount, namespaceCount }; } async function runBenchmark( fileCount: number, nsLevels: number, budgetMs: number, ): Promise { const { dir, classCount, namespaceCount } = generatePhpFixture(fileCount, nsLevels); let peakHeapMB = 0; const heapSampler = setInterval(() => { const heap = process.memoryUsage().heapUsed / 1024 / 1024; if (heap > peakHeapMB) peakHeapMB = heap; }, 50); try { const start = Date.now(); const result = await Promise.race([ runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), new Promise((_, reject) => setTimeout( () => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)), budgetMs, ), ), ]); const elapsedMs = Date.now() - start; return { fileCount, classCount, namespaceCount, elapsedMs, peakHeapMB: Math.round(peakHeapMB), nodeCount: result.graph.nodeCount, edgeCount: result.graph.relationshipCount, }; } finally { clearInterval(heapSampler); fs.rmSync(dir, { recursive: true, force: true }); } } function printResults(label: string, results: BenchResult[]) { console.log(`\n${label}`); console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐'); console.log('│ Files │ Classes │ NS Count │ Time (ms) │ Heap MB │ Nodes │ Edges │'); console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤'); for (const r of results) { console.log( `│ ${String(r.fileCount).padStart(8)} │ ${String(r.classCount).padStart(7)} │ ${String(r.namespaceCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`, ); } console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘'); if (results.length >= 2) { console.log('\nScaling ratios (time_ratio / file_ratio):'); for (let i = 1; i < results.length; i++) { const fileRatio = results[i].fileCount / results[i - 1].fileCount; const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; const scaling = timeRatio / fileRatio; console.log( ` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, ); } } } describe.skipIf(!BENCH_ENABLED)('PHP pipeline benchmark', () => { it('scales with file count (workers enabled)', async () => { const scales = [100, 250, 500]; const results: BenchResult[] = []; for (const fileCount of scales) { const nsLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4))); const result = await runBenchmark(fileCount, nsLevels, 180_000); results.push(result); console.log( ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, ); } printResults('PHP Pipeline — Workers Enabled', results); for (let i = 1; i < results.length; i++) { const fileRatio = results[i].fileCount / results[i - 1].fileCount; const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; expect(timeRatio / fileRatio).toBeLessThan(3); } }, 300_000); });