diff --git a/gitnexus/test/integration/parse-impl-large-fixture.test.ts b/gitnexus/test/integration/parse-impl-large-fixture.test.ts new file mode 100644 index 000000000..c51cdb400 --- /dev/null +++ b/gitnexus/test/integration/parse-impl-large-fixture.test.ts @@ -0,0 +1,175 @@ +/** + * U6 (B3 from PR #1693 review) — Multi-chunk pipeline integration with a + * wall-clock budget. + * + * The PR's headline claim is "analyze no longer hangs on TS-root-shaped + * loads". The unit suite already pins each resilience layer individually + * (worker-pool-resilience.test.ts), the deferred-extraction equivalence + * (parse-impl-deferred-extraction.test.ts — U7), and the chunk + * concurrency (parse-impl-chunk-concurrency.test.ts — U1). What was + * missing: a single end-to-end run that exercises the full chunked + * parse-and-resolve path on a multi-chunk fixture, BOUNDED by a + * wall-clock so a regression that re-introduces the hang fails this + * test loudly instead of slipping past via inequality assertions. + * + * Scope note: this test runs the sequential-fallback path (skipWorkers). + * The full "real workers + actually-pathological file" scenario from + * the plan requires a built `dist/parse-worker.js` and ~60s wall-clock + * per run, which is more appropriate for a CI-integration job than a + * vitest. Once the dist worker is wired into the test harness (a Phase 2 + * follow-up), this file can be extended to swap skipWorkers off. The + * load-bearing invariants verified here — multi-chunk parsing + * completes within a bounded budget and produces all expected symbols + * — catch the bulk of the regressions B3 was concerned about. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ORIGINAL_BUDGET = process.env.GITNEXUS_CHUNK_BYTE_BUDGET; +const WALL_CLOCK_BUDGET_MS = 30_000; + +function buildFixture(): Record { + const fixture: Record = {}; + for (let i = 0; i < 15; i++) { + fixture[`mod${i}.ts`] = `export function fn${i}(): number {\n return ${i};\n}\n`; + } + // A "realistically dense" file — 30 functions + a class + an interface + + // a re-export. Stands in for the kind of file that previously stalled the + // serial-extraction chunk loop. Pure-TS so the parser doesn't choke. + const complexFnLines = Array.from( + { length: 30 }, + (_, i) => `export function complex${i}(c: Config): string { return c.name + String(${i}); }`, + ).join('\n'); + fixture['complex.ts'] = ` +export interface Config { name: string; } +export class Service { + configure(c: Config): void { this.name = c.name; } + private name: string = ''; + describe(): string { return this.name; } +} +${complexFnLines} +`; + fixture['index.ts'] = + 'export { Service, type Config } from "./complex";\n' + + Array.from({ length: 15 }, (_, i) => `export { fn${i} } from "./mod${i}";`).join('\n') + + '\n'; + return fixture; +} + +async function runFixture(): Promise<{ + nodeCount: number; + relationshipCount: number; + symbolNames: Set; + elapsedMs: number; +}> { + const fixture = buildFixture(); + // Force multi-chunk parsing on the small fixture by lowering the byte + // budget below each file's size. parse-impl reads the budget at module + // load — vi.resetModules() forces a fresh module so the env takes effect. + process.env.GITNEXUS_CHUNK_BYTE_BUDGET = '64'; + vi.resetModules(); + const { runChunkedParseAndResolve } = + await import('../../src/core/ingestion/pipeline-phases/parse-impl.js'); + const { createKnowledgeGraph } = await import('../../src/core/graph/graph.js'); + + const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'parse-impl-large-fixture-')); + try { + for (const [name, content] of Object.entries(fixture)) { + fs.writeFileSync(path.join(repoPath, name), content); + } + const files = Object.keys(fixture); + const scanned = files.map((rel) => ({ + path: rel, + size: fs.statSync(path.join(repoPath, rel)).size, + })); + const graph = createKnowledgeGraph(); + + // Wrap the run in Promise.race so the wall-clock budget is enforced + // as an exception, not a >=/<= inequality assertion (DoD §2.7 — a + // bounds-only count is a regression-mask; a hard timeout is a + // hang-detector). If the run exceeds the budget, the rejection + // fails the test with a specific timeout error so the diagnostic + // surfaces the actual regression class. + const start = Date.now(); + await Promise.race([ + runChunkedParseAndResolve(graph, scanned, files, files.length, repoPath, start, () => {}, { + skipWorkers: true, + }), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `runChunkedParseAndResolve exceeded ${WALL_CLOCK_BUDGET_MS}ms wall-clock budget — likely the hang B3 was meant to prevent`, + ), + ), + WALL_CLOCK_BUDGET_MS, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + const symbolNames = new Set(); + for (const node of graph.nodes.values()) { + const name = (node.properties as { name?: string } | undefined)?.name; + if (typeof name === 'string') symbolNames.add(name); + } + + return { + nodeCount: graph.nodeCount, + relationshipCount: graph.relationshipCount, + symbolNames, + elapsedMs, + }; + } finally { + if (fs.existsSync(repoPath)) { + fs.rmSync(repoPath, { recursive: true, force: true }); + } + } +} + +describe('parse-impl wall-clock integration on multi-chunk fixture (U6 / B3)', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + if (ORIGINAL_BUDGET === undefined) { + delete process.env.GITNEXUS_CHUNK_BYTE_BUDGET; + } else { + process.env.GITNEXUS_CHUNK_BYTE_BUDGET = ORIGINAL_BUDGET; + } + }); + + it('completes a 17-file multi-chunk fixture within wall-clock and indexes every named symbol', async () => { + const result = await runFixture(); + + // Reaching this assertion means the Promise.race did NOT time out — + // the run completed in under WALL_CLOCK_BUDGET_MS. That alone is the + // primary B3 invariant: "does not hang on a multi-chunk workload". + // We additionally pin specific expected symbols below so a silent + // mid-chunk crash that exits 0 without producing graph data also + // fails this test, not just the hang case. + expect(result.elapsedMs).toBe(Math.min(result.elapsedMs, WALL_CLOCK_BUDGET_MS)); + + // All 15 plain function declarations must show up in the graph. + for (let i = 0; i < 15; i++) { + expect(result.symbolNames.has(`fn${i}`)).toBe(true); + } + + // The complex.ts surfaces: Service class, Config interface, configure + // method, and a representative sampling of the complex0..complex29 + // functions. Pin specific names (not just a count) so chunk-boundary + // truncation surfaces as a concrete missing-symbol failure with a + // specific diagnostic. + expect(result.symbolNames.has('Service')).toBe(true); + expect(result.symbolNames.has('Config')).toBe(true); + expect(result.symbolNames.has('configure')).toBe(true); + expect(result.symbolNames.has('describe')).toBe(true); + expect(result.symbolNames.has('complex0')).toBe(true); + expect(result.symbolNames.has('complex15')).toBe(true); + expect(result.symbolNames.has('complex29')).toBe(true); + }); +});