mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* fix(analyze): make incremental analyze skip the derived layers it can reuse (#3016) A warm incremental run only ever wrote a handful of files, but it still paid for the whole graph on the way out: Leiden ran over every node, flow extraction re-derived every process, and all FTS indexes were dropped and rebuilt from scratch. On a small edit that tail dominated the run, which is why "incremental" did not feel incremental. Reuse what the previous run already derived when the write plan allows it. The pipeline holds back community detection and flow extraction whenever the persisted metadata says this run is a candidate for a surgical write; the DB keeps its Community/Process rows instead of a wipe-and-rewrite; and the FTS sweep is narrowed to the indexes the run actually has to touch. The bet is placed before the pipeline and settled after it. Any plan that turns out to need a freshly derived layer — full rebuild, escalated write, or an incremental diff with deleted files — runs the held-back phases through `runDeferredDerivedPhases`, against the same graph and phase outputs, so its output is identical to never having skipped them. Correctness details worth naming, since each one silently loses data if got wrong: - The MEMBER_OF / STEP_IN_PROCESS edges of the changed files are snapshotted before the DETACH DELETE and reattached after the subgraph load. Both endpoints are matched by explicit label: `labels(n)[0]` over an unlabelled match returns an empty string on this engine, which produced a snapshot that restored nothing. - The FTS narrowing unions three sets — what the writeback deletes (a DB probe, because a symbol the edit removed is in no fresh graph but is still a row), what it inserts (the fresh graph), and what is missing right now (else a prior escalation's dropped indexes would never come back). An unreadable index catalog withdraws the narrowing entirely. - Deletions disqualify reuse outright: persisted derived rows can reference nodes this run removes, and nothing short of re-deriving can tell which. Covered by the existing incremental suites, including the incremental-equals-force byte-equivalence test and the #2589 drop-before-delete ordering test, plus unit tests for the new helpers. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): address #3102 review on derived reuse and FTS narrowing Re-run Leiden/flows unless the file-hash diff is empty, restore ENTRY_POINT_OF on the preserve path, always drop class_fts before Spring synthetic Class DML, and reject seeded duplicate phase names. Prettier and exact FTS drop-ordering assertions unblock CI and pin the #2589/#3016 contract. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(analyze): reuse FileHashDiff for derived-layer preserve Drop the count DTO, share phase-name uniqueness, and remove the File FTS sentinel that Class already makes unreachable. Refs #3102 Co-authored-by: Cursor <cursoragent@cursor.com> * style(analyze): prettier-wrap shouldPreservePersistedDerivedGraph quality / format failed on the Pick<FileHashDiff> signature wrapping. Refs #3102 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { runPipeline } from '../../src/core/ingestion/pipeline-phases/runner.js';
|
|
import type {
|
|
PipelinePhase,
|
|
PipelineContext,
|
|
PhaseResult,
|
|
} from '../../src/core/ingestion/pipeline-phases/types.js';
|
|
import { getPhaseOutput } from '../../src/core/ingestion/pipeline-phases/types.js';
|
|
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
|
|
|
function makeCtx(): PipelineContext {
|
|
return {
|
|
repoPath: '/tmp/test',
|
|
graph: createKnowledgeGraph(),
|
|
onProgress: () => {},
|
|
pipelineStart: Date.now(),
|
|
};
|
|
}
|
|
|
|
describe('runPipeline', () => {
|
|
it('executes phases in dependency order', async () => {
|
|
const order: string[] = [];
|
|
|
|
const phaseA: PipelinePhase<string> = {
|
|
name: 'a',
|
|
deps: [],
|
|
async execute() {
|
|
order.push('a');
|
|
return 'resultA';
|
|
},
|
|
};
|
|
|
|
const phaseB: PipelinePhase<string> = {
|
|
name: 'b',
|
|
deps: ['a'],
|
|
async execute(_ctx, deps) {
|
|
const a = getPhaseOutput<string>(deps, 'a');
|
|
order.push('b');
|
|
return `${a}+B`;
|
|
},
|
|
};
|
|
|
|
const phaseC: PipelinePhase<string> = {
|
|
name: 'c',
|
|
deps: ['a'],
|
|
async execute(_ctx, deps) {
|
|
const a = getPhaseOutput<string>(deps, 'a');
|
|
order.push('c');
|
|
return `${a}+C`;
|
|
},
|
|
};
|
|
|
|
const phaseD: PipelinePhase<string> = {
|
|
name: 'd',
|
|
deps: ['b', 'c'],
|
|
async execute(_ctx, deps) {
|
|
const b = getPhaseOutput<string>(deps, 'b');
|
|
const c = getPhaseOutput<string>(deps, 'c');
|
|
order.push('d');
|
|
return `${b}|${c}`;
|
|
},
|
|
};
|
|
|
|
const results = await runPipeline([phaseD, phaseA, phaseC, phaseB], makeCtx());
|
|
|
|
// A must run before B and C; B and C must run before D
|
|
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
|
expect(order.indexOf('a')).toBeLessThan(order.indexOf('c'));
|
|
expect(order.indexOf('b')).toBeLessThan(order.indexOf('d'));
|
|
expect(order.indexOf('c')).toBeLessThan(order.indexOf('d'));
|
|
|
|
// Check outputs are correctly threaded
|
|
expect(results.get('d')?.output).toBe('resultA+B|resultA+C');
|
|
});
|
|
|
|
it('passes shared PipelineContext to every phase', async () => {
|
|
const ctx = makeCtx();
|
|
const seenContexts: PipelineContext[] = [];
|
|
|
|
const phase: PipelinePhase<void> = {
|
|
name: 'test',
|
|
deps: [],
|
|
async execute(c) {
|
|
seenContexts.push(c);
|
|
},
|
|
};
|
|
|
|
await runPipeline([phase], ctx);
|
|
expect(seenContexts).toHaveLength(1);
|
|
expect(seenContexts[0]).toBe(ctx);
|
|
});
|
|
|
|
it('records timing metadata in PhaseResult', async () => {
|
|
const phase: PipelinePhase<number> = {
|
|
name: 'slow',
|
|
deps: [],
|
|
async execute() {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
return 42;
|
|
},
|
|
};
|
|
|
|
const results = await runPipeline([phase], makeCtx());
|
|
const result = results.get('slow')!;
|
|
expect(result.phaseName).toBe('slow');
|
|
expect(result.output).toBe(42);
|
|
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('rejects duplicate phase names', async () => {
|
|
const phaseA: PipelinePhase = {
|
|
name: 'dup',
|
|
deps: [],
|
|
async execute() {},
|
|
};
|
|
const phaseB: PipelinePhase = {
|
|
name: 'dup',
|
|
deps: [],
|
|
async execute() {},
|
|
};
|
|
|
|
await expect(runPipeline([phaseA, phaseB], makeCtx())).rejects.toThrow(/Duplicate phase name/);
|
|
});
|
|
|
|
it('rejects missing dependencies', async () => {
|
|
const phase: PipelinePhase = {
|
|
name: 'orphan',
|
|
deps: ['nonexistent'],
|
|
async execute() {},
|
|
};
|
|
|
|
await expect(runPipeline([phase], makeCtx())).rejects.toThrow(/depends on 'nonexistent'/);
|
|
});
|
|
|
|
it('rejects cyclic dependencies', async () => {
|
|
const phaseA: PipelinePhase = {
|
|
name: 'x',
|
|
deps: ['y'],
|
|
async execute() {},
|
|
};
|
|
const phaseB: PipelinePhase = {
|
|
name: 'y',
|
|
deps: ['x'],
|
|
async execute() {},
|
|
};
|
|
|
|
await expect(runPipeline([phaseA, phaseB], makeCtx())).rejects.toThrow(/Cycle detected/);
|
|
});
|
|
|
|
it('reports only cycle members (not transitive dependents) in cycle error', async () => {
|
|
// A <-> B is the actual cycle. C, D, E are downstream and would also have
|
|
// inDegree > 0 after Kahn's drains, but they are NOT cycle members.
|
|
const phases: PipelinePhase[] = [
|
|
{ name: 'a', deps: ['b'], async execute() {} },
|
|
{ name: 'b', deps: ['a'], async execute() {} },
|
|
{ name: 'c', deps: ['a'], async execute() {} },
|
|
{ name: 'd', deps: ['c'], async execute() {} },
|
|
{ name: 'e', deps: ['c'], async execute() {} },
|
|
];
|
|
|
|
let caught: Error | undefined;
|
|
try {
|
|
await runPipeline(phases, makeCtx());
|
|
} catch (err) {
|
|
caught = err as Error;
|
|
}
|
|
|
|
expect(caught).toBeDefined();
|
|
const msg = caught!.message;
|
|
// Cycle path must include both A and B
|
|
expect(msg).toMatch(/Cycle detected in pipeline phases: /);
|
|
expect(msg).toMatch(/\ba\b/);
|
|
expect(msg).toMatch(/\bb\b/);
|
|
// Transitive dependents must NOT appear in the cycle path itself,
|
|
// they should be summarized in the parenthetical.
|
|
const pathSection = msg.split('(')[0];
|
|
expect(pathSection).not.toMatch(/\bc\b/);
|
|
expect(pathSection).not.toMatch(/\bd\b/);
|
|
expect(pathSection).not.toMatch(/\be\b/);
|
|
expect(msg).toMatch(/3 transitive dependents blocked/);
|
|
});
|
|
|
|
it('reports the full path for a 3-phase cycle', async () => {
|
|
// A -> B -> C -> A
|
|
const phases: PipelinePhase[] = [
|
|
{ name: 'a', deps: ['c'], async execute() {} },
|
|
{ name: 'b', deps: ['a'], async execute() {} },
|
|
{ name: 'c', deps: ['b'], async execute() {} },
|
|
];
|
|
|
|
let caught: Error | undefined;
|
|
try {
|
|
await runPipeline(phases, makeCtx());
|
|
} catch (err) {
|
|
caught = err as Error;
|
|
}
|
|
|
|
expect(caught).toBeDefined();
|
|
const msg = caught!.message;
|
|
expect(msg).toMatch(/Cycle detected in pipeline phases: /);
|
|
// All three names must appear
|
|
expect(msg).toMatch(/\ba\b/);
|
|
expect(msg).toMatch(/\bb\b/);
|
|
expect(msg).toMatch(/\bc\b/);
|
|
// Path uses the " -> " arrow separator
|
|
expect(msg).toMatch(/ -> /);
|
|
// No transitive-dependent suffix when every leftover IS a cycle member
|
|
expect(msg).not.toMatch(/transitive dependent/);
|
|
});
|
|
|
|
it("emits a terminal 'error' progress event on cycle detection", async () => {
|
|
const events: { phase: string; message: string; detail?: string }[] = [];
|
|
const ctx: PipelineContext = {
|
|
...makeCtx(),
|
|
onProgress: (p) => {
|
|
events.push({ phase: p.phase, message: p.message, detail: p.detail });
|
|
},
|
|
};
|
|
|
|
const phases: PipelinePhase[] = [
|
|
{ name: 'x', deps: ['y'], async execute() {} },
|
|
{ name: 'y', deps: ['x'], async execute() {} },
|
|
];
|
|
|
|
await expect(runPipeline(phases, ctx)).rejects.toThrow(/Cycle detected/);
|
|
|
|
const errorEvents = events.filter((e) => e.phase === 'error');
|
|
expect(errorEvents).toHaveLength(1);
|
|
expect(errorEvents[0].detail).toMatch(/Cycle detected/);
|
|
});
|
|
|
|
it('executes a single root phase with no deps', async () => {
|
|
const phase: PipelinePhase<string> = {
|
|
name: 'root',
|
|
deps: [],
|
|
async execute() {
|
|
return 'hello';
|
|
},
|
|
};
|
|
|
|
const results = await runPipeline([phase], makeCtx());
|
|
expect(results.get('root')?.output).toBe('hello');
|
|
});
|
|
|
|
it('handles a linear chain correctly', async () => {
|
|
const order: string[] = [];
|
|
|
|
const phases: PipelinePhase<number>[] = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
const idx = i;
|
|
phases.push({
|
|
name: `step${i}`,
|
|
deps: i > 0 ? [`step${i - 1}`] : [],
|
|
async execute(_ctx, deps) {
|
|
if (idx > 0) {
|
|
const prev = getPhaseOutput<number>(deps, `step${idx - 1}`);
|
|
order.push(`step${idx}`);
|
|
return prev + 1;
|
|
}
|
|
order.push(`step${idx}`);
|
|
return 0;
|
|
},
|
|
});
|
|
}
|
|
|
|
const results = await runPipeline(phases, makeCtx());
|
|
expect(results.get('step4')?.output).toBe(4);
|
|
expect(order).toEqual(['step0', 'step1', 'step2', 'step3', 'step4']);
|
|
});
|
|
|
|
it('wraps phase Error with phase name and preserves cause', async () => {
|
|
const original = new Error('boom');
|
|
const phase: PipelinePhase = {
|
|
name: 'failing',
|
|
deps: [],
|
|
async execute() {
|
|
throw original;
|
|
},
|
|
};
|
|
|
|
await expect(runPipeline([phase], makeCtx())).rejects.toThrow(/Phase 'failing' failed: boom/);
|
|
|
|
try {
|
|
await runPipeline([phase], makeCtx());
|
|
throw new Error('expected runPipeline to reject');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect((err as Error).message).toMatch(/Phase 'failing' failed: boom/);
|
|
expect((err as Error & { cause?: unknown }).cause).toBe(original);
|
|
}
|
|
});
|
|
|
|
it('surfaces phase name when phase throws a non-Error value', async () => {
|
|
const phaseString: PipelinePhase = {
|
|
name: 'string-thrower',
|
|
deps: [],
|
|
async execute() {
|
|
throw 'oops';
|
|
},
|
|
};
|
|
|
|
await expect(runPipeline([phaseString], makeCtx())).rejects.toThrow(
|
|
/Phase 'string-thrower' failed: oops/,
|
|
);
|
|
|
|
const phaseNumber: PipelinePhase = {
|
|
name: 'number-thrower',
|
|
deps: [],
|
|
async execute() {
|
|
throw 42;
|
|
},
|
|
};
|
|
|
|
await expect(runPipeline([phaseNumber], makeCtx())).rejects.toThrow(
|
|
/Phase 'number-thrower' failed: 42/,
|
|
);
|
|
});
|
|
|
|
it("emits a terminal 'error' progress event exactly once on phase failure", async () => {
|
|
const events: { phase: string; message: string; detail?: string }[] = [];
|
|
const ctx: PipelineContext = {
|
|
...makeCtx(),
|
|
onProgress: (p) => {
|
|
events.push({ phase: p.phase, message: p.message, detail: p.detail });
|
|
},
|
|
};
|
|
|
|
const phase: PipelinePhase = {
|
|
name: 'failing',
|
|
deps: [],
|
|
async execute() {
|
|
throw new Error('kaboom');
|
|
},
|
|
};
|
|
|
|
await expect(runPipeline([phase], ctx)).rejects.toThrow(/Phase 'failing' failed/);
|
|
|
|
const errorEvents = events.filter((e) => e.phase === 'error');
|
|
expect(errorEvents).toHaveLength(1);
|
|
expect(errorEvents[0].message).toMatch(/failing/);
|
|
expect(errorEvents[0].detail).toBe('kaboom');
|
|
});
|
|
|
|
it('still rejects when onProgress handler throws during error reporting', async () => {
|
|
const original = new Error('underlying');
|
|
const ctx: PipelineContext = {
|
|
...makeCtx(),
|
|
onProgress: () => {
|
|
throw new Error('handler exploded');
|
|
},
|
|
};
|
|
|
|
const phase: PipelinePhase = {
|
|
name: 'failing',
|
|
deps: [],
|
|
async execute() {
|
|
throw original;
|
|
},
|
|
};
|
|
|
|
try {
|
|
await runPipeline([phase], ctx);
|
|
throw new Error('expected runPipeline to reject');
|
|
} catch (err) {
|
|
// The original phase error must win, not the handler's error.
|
|
expect((err as Error).message).toMatch(/Phase 'failing' failed: underlying/);
|
|
expect((err as Error & { cause?: unknown }).cause).toBe(original);
|
|
}
|
|
});
|
|
|
|
it('only exposes declared deps to each phase', async () => {
|
|
const phaseA: PipelinePhase<string> = {
|
|
name: 'a',
|
|
deps: [],
|
|
async execute() {
|
|
return 'resultA';
|
|
},
|
|
};
|
|
|
|
const phaseB: PipelinePhase<string> = {
|
|
name: 'b',
|
|
deps: ['a'],
|
|
async execute() {
|
|
return 'resultB';
|
|
},
|
|
};
|
|
|
|
// C depends on B but not A — should not see A's result
|
|
const phaseC: PipelinePhase<string> = {
|
|
name: 'c',
|
|
deps: ['b'],
|
|
async execute(_ctx, deps) {
|
|
expect(deps.has('b')).toBe(true);
|
|
expect(deps.has('a')).toBe(false);
|
|
return 'resultC';
|
|
},
|
|
};
|
|
|
|
await runPipeline([phaseA, phaseB, phaseC], makeCtx());
|
|
});
|
|
});
|
|
|
|
describe('runPipeline with seeded results (#3016 deferred derived phases)', () => {
|
|
const seeded = (name: string, output: unknown): ReadonlyMap<string, PhaseResult<unknown>> =>
|
|
new Map([[name, { phaseName: name, output, durationMs: 0 }]]);
|
|
|
|
it('satisfies a dependency from the seed instead of demanding the phase', async () => {
|
|
const later: PipelinePhase<string> = {
|
|
name: 'later',
|
|
deps: ['earlier'],
|
|
async execute(_ctx, deps) {
|
|
return `${getPhaseOutput<string>(deps, 'earlier')}+later`;
|
|
},
|
|
};
|
|
|
|
const results = await runPipeline([later], makeCtx(), seeded('earlier', 'earlierOutput'));
|
|
|
|
expect(getPhaseOutput<string>(results, 'later')).toBe('earlierOutput+later');
|
|
});
|
|
|
|
it('returns the seeded results alongside the newly run ones', async () => {
|
|
const later: PipelinePhase<string> = {
|
|
name: 'later',
|
|
deps: ['earlier'],
|
|
execute: async () => 'x',
|
|
};
|
|
|
|
const results = await runPipeline([later], makeCtx(), seeded('earlier', 'earlierOutput'));
|
|
|
|
expect([...results.keys()].sort()).toEqual(['earlier', 'later']);
|
|
});
|
|
|
|
it('does not re-run a seeded phase', async () => {
|
|
let ran = 0;
|
|
const earlier: PipelinePhase<string> = {
|
|
name: 'earlier',
|
|
deps: [],
|
|
async execute() {
|
|
ran++;
|
|
return 'fresh';
|
|
},
|
|
};
|
|
|
|
await runPipeline([earlier], makeCtx(), seeded('earlier', 'seeded'));
|
|
|
|
// The seed already carries this phase's output, so the runner must treat it
|
|
// as a duplicate registration rather than silently executing it twice.
|
|
expect(ran).toBe(0);
|
|
});
|
|
|
|
it('still rejects a dependency that is neither registered nor seeded', async () => {
|
|
const later: PipelinePhase<string> = {
|
|
name: 'later',
|
|
deps: ['missing'],
|
|
execute: async () => 'x',
|
|
};
|
|
|
|
await expect(runPipeline([later], makeCtx(), seeded('earlier', 'e'))).rejects.toThrow(
|
|
/depends on 'missing', which is not registered/,
|
|
);
|
|
});
|
|
|
|
it('rejects duplicate phase names even when one copy is also seeded', async () => {
|
|
const dup: PipelinePhase = {
|
|
name: 'earlier',
|
|
deps: [],
|
|
async execute() {},
|
|
};
|
|
await expect(runPipeline([dup, dup], makeCtx(), seeded('earlier', 'seed'))).rejects.toThrow(
|
|
/Duplicate phase name/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getPhaseOutput', () => {
|
|
it('retrieves typed output from dependency map', () => {
|
|
const deps = new Map<string, PhaseResult<unknown>>();
|
|
deps.set('test', { phaseName: 'test', output: { value: 42 }, durationMs: 0 });
|
|
|
|
const result = getPhaseOutput<{ value: number }>(deps, 'test');
|
|
expect(result.value).toBe(42);
|
|
});
|
|
|
|
it('throws for missing phase', () => {
|
|
const deps = new Map<string, PhaseResult<unknown>>();
|
|
|
|
expect(() => getPhaseOutput(deps, 'missing')).toThrow(/Phase 'missing' not found/);
|
|
});
|
|
});
|