GitNexus/gitnexus/test/unit/parse-impl-progress-monotonic.test.ts
Gergo Magyar 89dbebdf61 feat(parse-impl): monotonic progress + verbose-gated throughput log + seed-before-build
Resolves PR #1693 review M2, M3, L1, L5 in a single parse-impl.ts pass:

M2 - Monotonic progress through deferred phase (no more "stuck at 82%")
  Previously the deferred resolution stages (imports, heritage, routes,
  calls) all emitted percent: 82 — the UI looked frozen for the duration
  of the deferred work, which on large repos is several seconds to minutes
  and visually identical to the hang PR #1693 set out to fix.
  Redistributed:
    parse phase:  20-70 (was 20-82)
    imports:      70-75
    heritage:     75-80
    routes:       80-85
    calls:        85-95
  Each deferred stage now advances through its own band via the existing
  per-batch progress callback. Skipped stages (zero deferred input) leave
  their band as a no-op jump - the next stage still starts at its own
  band, preserving strict monotonicity. The "no parseable files" early
  return now jumps to 95 (was 82), and the duplicate "Parsing N files..."
  announcement is suppressed when totalParseable === 0 to avoid a
  non-monotonic 95 -> 20 regression that pre-existed (uncovered by the
  new monotonic test).

M3 - Throughput log gated on `--verbose`, not just NODE_ENV=development
  The per-chunk files/s log was gated on `isDev`, so operators running
  `gitnexus analyze --verbose` in a production install never saw it.
  Now fires when (isDev || isVerboseIngestionEnabled()) — matches the
  documented promise that `--verbose` shows tuning observability.

L1 - Typo rename: `chunkChunkStartMs` -> `chunkStartMs`

L5 - `buildExportedTypeMapFromGraph` runs BEFORE `seedCrossFileReceiverTypes`
  Previously the seeding branch was reached with `exportedTypeMap.size === 0`
  in the worker path (the map was only built far below, AFTER the seeding
  branch), so the seed dead-coded itself silently and call resolution
  never got the cross-file receiver-type enrichment. Now the map is
  populated from the in-progress graph before the seed call; the
  post-parse builder remains as a defensive sequential-path fallback,
  guarded by `size === 0` so we don't pay the cost twice on the worker
  path. Net win: cross-file CALLS edges that previously had no receiver
  type now get enriched.

New test: parse-impl-progress-monotonic.test.ts
  Asserts the emitted percent stream is strictly non-decreasing across
  the parse + deferred phases, and that the deferred band (>=70) is
  actually reached. Also pins the "no parseable files" path to exactly
  [95] so the 95 -> 20 regression we just fixed can't re-emerge.
2026-05-20 08:30:25 +01:00

120 lines
4.3 KiB
TypeScript

/**
* 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 via the deterministic sequential-fallback path
* (`skipWorkers: true`) and asserts the recorded percent stream is strictly
* non-decreasing AND reaches the deferred band (>=70) before returning.
*/
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, string>): 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);
},
{ skipWorkers: true },
);
// Must have emitted at least one progress update.
expect(percents.length).toBeGreaterThan(0);
// Strict monotonic non-decreasing across the whole stream.
for (let i = 1; i < percents.length; i++) {
expect(percents[i]).toBe(Math.max(percents[i - 1], percents[i]));
}
// 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);
// The final emitted percent must land at or below the post-parse ceiling
// (95). The orchestrator (run-analyze) drives 95-100 itself; parse-impl
// never emits >95.
expect(percents[percents.length - 1]).toBe(Math.min(percents[percents.length - 1], 95));
});
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);
},
{ skipWorkers: true },
);
// 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]);
});
});