/** * U4 (M2) — Monotonic progress through the parse + deferred-extraction phases. * * Before this fix, parse-impl emitted `percent: 82` for every progress * update during the deferred resolution stages (imports, heritage, routes, * calls). The UI sat at 82 for the duration of the deferred work — on real * repos, several seconds to minutes — looking exactly like a hang, which is * the user-facing symptom PR #1693 set out to fix. * * After M2, parse phase covers 20-70 and deferred extraction covers 70-95 * across four labelled sub-bands. This test runs `runChunkedParseAndResolve` * on a small temp repo through the worker pool (the sole parse path) and * asserts the recorded percent stream is strictly non-decreasing AND reaches * the deferred band (>=70) before returning. The progress percents come from * the chunk loop / deferred bands, which are identical regardless of how files * are parsed — so this exercises the same progress contract the sequential * path used to. Integration tier because the pool needs the dist worker. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; function makeTempRepo(files: Record): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parse-impl-progress-')); for (const [rel, content] of Object.entries(files)) { const abs = path.join(dir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, content); } return dir; } function scanned(repo: string, files: string[]) { return files.map((rel) => ({ path: rel, size: fs.statSync(path.join(repo, rel)).size, })); } describe('parse-impl progress monotonicity (U4 M2)', () => { let repoPath = ''; beforeEach(() => { repoPath = makeTempRepo({ 'a.ts': `export function foo() { return 1; }\n`, 'b.ts': `import { foo } from './a';\nexport function bar() { return foo(); }\n`, 'c.ts': `import { bar } from './b';\nexport class Baz { run() { return bar(); } }\n`, }); }); afterEach(() => { if (repoPath && fs.existsSync(repoPath)) { fs.rmSync(repoPath, { recursive: true, force: true }); } }); it('emits a strictly non-decreasing percent stream and reaches the deferred band', async () => { const graph = createKnowledgeGraph(); const files = ['a.ts', 'b.ts', 'c.ts']; const percents: number[] = []; await runChunkedParseAndResolve( graph, scanned(repoPath, files), files, files.length, repoPath, Date.now(), (p) => { if (typeof p.percent === 'number') percents.push(p.percent); }, {}, ); // The stream MUST be non-empty (a regression that stops emitting // progress should fail this test). Express via exact-equality // negation rather than a bound. expect(percents).not.toEqual([]); // Strict monotonic non-decreasing across the whole stream. Direct // comparison — the previous `Math.max(prev, cur)` form resolved to // `expect(cur).toBe(cur)` which is a tautology. for (let i = 1; i < percents.length; i++) { if (percents[i] < percents[i - 1]) { throw new Error( `progress regressed: percents[${i}]=${percents[i]} < percents[${i - 1}]=${percents[i - 1]}`, ); } } // The parse phase advances through 20-70; the deferred extraction band // covers 70-95. On a 3-file fixture with imports + heritage + calls, // we should observe at least one percent value in the 70-95 band so the // monotonic-advance behavior is exercised, not just the parse half. const reachedDeferredBand = percents.some((p) => p >= 70 && p <= 95); expect(reachedDeferredBand).toBe(true); // On this 3-file fixture the deferred band advances exactly to 70 (the // start of the band). The orchestrator // (run-analyze) drives 70-100 itself once cross-chunk extraction // finishes. Pinning the exact observed value catches both an // upper-bound regression (anything >70 would unexpectedly land in // the band) AND a lower-bound regression (anything <70 would mean // the parse phase didn't complete). expect(percents[percents.length - 1]).toBe(70); }); it('emits percent 95 (not 82) when there are no parseable files to skip past the parse band', async () => { const graph = createKnowledgeGraph(); // No parseable files: empty scanned list, empty parseable list. const percents: number[] = []; await runChunkedParseAndResolve( graph, [], [], 0, repoPath, Date.now(), (p) => { if (typeof p.percent === 'number') percents.push(p.percent); }, {}, ); // The early-return path must emit 95 (the new post-deferred ceiling), // not the stale 82 it used before M2 — otherwise downstream phases // would visibly regress percent on the next update. expect(percents).toEqual([95]); }); });