GitNexus/gitnexus/test/integration/php-pipeline-benchmark.test.ts
Lucas van Staden 1c4993251c
fix(php): synthesize module scope for namespace-less PHP files (.phtml) (#1801)
* fix(php): phtml scope synthesis with full-file range + O(1) Step 4 lookup (#1801, #1803)

Address PR #1801 review findings and complete #1803 fix:

scope-extractor.ts:
- Synthetic Module scope uses full-file range (computed from existing
  drafts) so positionIndex containment works for top-level references
  in ERROR-root .phtml files
- Orphan scope re-parenting done on drafts in extract() by replacing
  with new drafts — no mutation of readonly fields, no PHP-specific
  logic in shared buildScopeTree
- Dead matchCount parameter removed from ensureModuleScope

namespace-siblings.ts:
- Step 4 parsedFiles.find() replaced with pre-built Map for O(1) lookup
  (was O(n²) with 16K files = ~256M comparisons)

* test(php): add pipeline benchmark for scaling regression detection

Synthetic PHP fixture generator (N files × M namespaces × K classes)
with cross-namespace imports and calls. Measures wall-clock, peak heap,
node/edge counts at 100/250/500 file scales with worker pool enabled.

Results on current branch:
- 100 files: 982ms, 65MB (9.8ms/file)
- 250 files: 1310ms, 70MB (5.2ms/file)
- 500 files: 2006ms, 92MB (4.0ms/file)
- Scaling: sublinear (0.53x-0.77x ratio)

Gated behind GITNEXUS_BENCH=1 so it does not run in normal CI.

* chore: trigger CI

* fix: prettier formatting + update scope-extractor test for synthesis behavior

* fix: extend synthetic Module range to all captures + update integration test

Address CI failure and review findings:
- ensureModuleScope now computes range from ALL captures (scope,
  declaration, reference, type-binding) not just scope drafts. This
  ensures top-level references after the last inner scope are covered.
- Update parse-worker-scope-integration test for synthesis behavior.
- Update extract() docstring to document synthesis contract.

---------

Co-authored-by: Test <test@example.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-24 20:37:17 +01:00

199 lines
6.9 KiB
TypeScript

/**
* 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
* skipWorkers to test the sequential fallback path.
*/
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 = [
'<?php',
`namespace ${ns};`,
'',
ns !== crossNs ? `use ${crossNs}\\${crossClass};` : '',
'',
`class ${className}`,
'{',
` private int $id;`,
` private string $name;`,
'',
` public function getId(): int`,
' {',
' return $this->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<BenchResult> {
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<never>((_, 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);
});