import { describe, it, expect, vi, beforeEach } from 'vitest'; const executeQueryMock = vi.fn(); const executeParameterizedMock = vi.fn(); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), executeQuery: (...args: any[]) => executeQueryMock(...args), executeParameterized: (...args: any[]) => executeParameterizedMock(...args), closeLbug: vi.fn(), isLbugReady: vi.fn().mockReturnValue(true), }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), executeQuery: (...args: any[]) => executeQueryMock(...args), executeParameterized: (...args: any[]) => executeParameterizedMock(...args), closeLbug: vi.fn(), isLbugReady: vi.fn().mockReturnValue(true), }; }); import { LocalBackend } from '../../src/mcp/local/local-backend'; import { collectImpactSymbolUids } from '../../src/core/group/cross-impact'; function makeBackend() { const backend = new LocalBackend(); const repoHandle = { id: 'repo1', name: 'repo1', repoPath: '/tmp/repo', storagePath: '/tmp/repo/.gitnexus', lbugPath: '/tmp/repo/.gitnexus/lbug', indexedAt: 'now', lastCommit: 'c', stats: {}, } as any; (backend as any).repos.set(repoHandle.id, repoHandle); (backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined); return { backend, repoHandle }; } // The BFS frontier query is now parameterized (bound $frontierIds/$relTypes, // #1907 U3), so the caller rows come back through executeParameterizedMock // (matched on `r.type IN`) rather than executeQueryMock. Symbol resolution and // the label-enrichment UNION still fall through to the default symbol row. function setupMultiDepthHub(d1Count: number, d2Count: number) { let depth = 0; executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; // The #1858 epistemic-boundary probe (computeEpistemicBoundary) runs // concurrently with the BFS and also matches `r.type IN`, but targets the // `iface` alias. Return empty so it stays `epistemic: 'exact'` and does not // consume a depth slot from the frontier counter below. if (query.includes('iface')) return []; if (query.includes('r.type IN')) { depth++; const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0; const res: any[] = []; for (let i = 0; i < count; i++) { res.push({ id: `d${depth}-caller-${i}`, name: `d${depth}caller${i}`, filePath: `src/d${depth}-caller-${i}.ts`, relType: 'CALLS', confidence: null, }); } return res; } return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; }); executeQueryMock.mockImplementation(async () => []); } // `impacted` is ordered by node id, and ids are STRINGS — so `caller-107` sorts // before `caller-11`, not after. That was always production's order (the frontier // query used to carry `ORDER BY id`), but the mock returns rows unsorted and the // old code trusted the DB, so these expectations used to encode the mock's numeric // insertion order — an order the real engine never produced. #2787 moved the sort // into JS, which is what finally makes the mocked path agree with production. /** The nth caller in id (code-unit) order, matching what `_impactImpl` returns. */ function callerAtIndex(total: number, index: number): string { const sorted = Array.from({ length: total }, (_, i) => `caller-${i}`).sort(); return sorted[index].replace('caller-', 'caller'); } function setupHubSymbol(count: number) { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; // See setupMultiDepthHub — keep the #1858 epistemic probe from matching the // `r.type IN` caller branch below. if (query.includes('iface')) return []; if (query.includes('r.type IN')) { const res: any[] = []; for (let i = 0; i < count; i++) { res.push({ id: `caller-${i}`, name: `caller${i}`, filePath: `src/caller-${i}.ts`, relType: 'CALLS', confidence: null, }); } return res; } return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; }); executeQueryMock.mockImplementation(async () => []); } describe('impact: pagination and summaryOnly (#414)', () => { beforeEach(() => { vi.clearAllMocks(); }); it('returns byDepthCounts in default response', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(50); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, }); expect(res.byDepthCounts).toEqual({ 1: 50 }); expect(res.impactedCount).toBe(50); expect(res.byDepth).toBeDefined(); expect(res.byDepth[1].length).toBe(50); }); it('limit caps byDepth symbols per depth level', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(200); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, limit: 20, }); expect(res.impactedCount).toBe(200); expect(res.byDepthCounts).toEqual({ 1: 200 }); expect(res.byDepth[1].length).toBe(20); expect(res.pagination).toEqual({ limit: 20, offset: 0, truncated: true, }); }); it('offset skips symbols before applying limit', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(200); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, limit: 20, offset: 10, }); expect(res.byDepth[1].length).toBe(20); expect(res.byDepth[1][0].name).toBe(callerAtIndex(200, 10)); expect(res.pagination).toEqual({ limit: 20, offset: 10, truncated: true, }); }); it('no pagination metadata when all results fit within limit', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(30); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, limit: 50, }); expect(res.byDepth[1].length).toBe(30); expect(res.pagination).toBeUndefined(); }); it('default limit of 100 caps large result sets', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(400); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, }); expect(res.impactedCount).toBe(400); expect(res.byDepthCounts).toEqual({ 1: 400 }); expect(res.byDepth[1].length).toBe(100); expect(res.pagination).toEqual({ limit: 100, offset: 0, truncated: true, }); }); it('summaryOnly omits byDepth entirely', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(400); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, summaryOnly: true, }); expect(res.impactedCount).toBe(400); expect(res.risk).toBe('CRITICAL'); expect(res.byDepthCounts).toEqual({ 1: 400 }); expect(res.summary.direct).toBe(400); expect(res.affected_processes).toBeDefined(); expect(res.affected_modules).toBeDefined(); expect(res.byDepth).toBeUndefined(); expect(res.pagination).toBeUndefined(); }); it('summaryOnly response is small even for hub symbols', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(800); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, summaryOnly: true, }); expect(res.impactedCount).toBe(800); expect(res.byDepthCounts).toEqual({ 1: 800 }); expect(res.byDepth).toBeUndefined(); expect(res.pagination).toBeUndefined(); }); it.each([ ['skipEpistemic', { skipEpistemic: true }], ['summaryOnly', { summaryOnly: true }], ])('%s suppresses Class bean metadata lookups', async (_name, suppression) => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(1); await (backend as any)._runImpactBFS( repoHandle, { id: 'hub1', name: 'HubClass' }, 'Class', 'upstream', { maxDepth: 1, relationTypes: ['CALLS'], includeTests: false, minConfidence: 0, ...suppression, }, ); expect( executeParameterizedMock.mock.calls.some((args) => String(args[1] ?? '').includes('frameworkAnnotations'), ), ).toBe(false); }); it('limit clamps to 1–10000 range', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(10); const resZero = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, limit: 0, }); expect(resZero.byDepth[1].length).toBe(1); const resNeg = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, limit: -5, }); expect(resNeg.byDepth[1].length).toBe(1); }); it('multi-depth: each depth paginates independently', async () => { const { backend, repoHandle } = makeBackend(); setupMultiDepthHub(150, 50); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 2, limit: 30, }); expect(res.impactedCount).toBe(200); expect(res.byDepthCounts).toEqual({ 1: 150, 2: 50 }); expect(res.byDepth[1].length).toBe(30); expect(res.byDepth[2].length).toBe(30); expect(res.pagination.truncated).toBe(true); }); it('offset-only truncation: pagination metadata present when offset > 0 even if tail fits', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(50); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, limit: 100, offset: 10, }); expect(res.byDepth[1].length).toBe(40); expect(res.pagination).toBeDefined(); expect(res.pagination.truncated).toBe(true); expect(res.pagination.offset).toBe(10); }); it('offset past end: returns empty byDepth with pagination metadata', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(50); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, limit: 20, offset: 100, }); expect(res.impactedCount).toBe(50); expect(res.byDepthCounts).toEqual({ 1: 50 }); expect(res.byDepth[1].length).toBe(0); expect(res.pagination).toBeDefined(); expect(res.pagination.truncated).toBe(true); }); it('float limit/offset are truncated to integers', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(50); const res = await (backend as any)._impactImpl(repoHandle, { target: 'HubSymbol', direction: 'upstream', maxDepth: 1, limit: 20.7, offset: 5.9, }); expect(res.byDepth[1].length).toBe(20); expect(res.byDepth[1][0].name).toBe(callerAtIndex(50, 5)); expect(res.pagination.limit).toBe(20); expect(res.pagination.offset).toBe(5); }); it('_runImpactBFS without limit returns all symbols (internal caller path)', async () => { const { backend, repoHandle } = makeBackend(); setupHubSymbol(400); const sym = { id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }; const res = await (backend as any)._runImpactBFS(repoHandle, sym, 'Function', 'upstream', { maxDepth: 1, relationTypes: ['CALLS'], includeTests: false, minConfidence: 0, }); expect(res.impactedCount).toBe(400); expect(res.byDepth[1].length).toBe(400); expect(res.pagination).toBeUndefined(); }); }); describe('collectImpactSymbolUids with paginated results', () => { it('collects all UIDs from complete byDepth', () => { const impact = { target: { id: 'target1', filePath: 'src/target.ts' }, byDepth: { 1: [ { id: 'a', filePath: 'src/a.ts' }, { id: 'b', filePath: 'src/b.ts' }, { id: 'c', filePath: 'src/c.ts' }, ], }, }; const { uids } = collectImpactSymbolUids(impact, undefined); expect(uids).toContain('target1'); expect(uids).toContain('a'); expect(uids).toContain('b'); expect(uids).toContain('c'); expect(uids.length).toBe(4); }); it('only gets paginated subset when byDepth is capped', () => { const impact = { target: { id: 'target1', filePath: 'src/target.ts' }, byDepthCounts: { 1: 300 }, byDepth: { 1: Array.from({ length: 100 }, (_, i) => ({ id: `sym-${i}`, filePath: `src/sym-${i}.ts`, })), }, pagination: { limit: 100, offset: 0, truncated: true }, }; const { uids } = collectImpactSymbolUids(impact, undefined); expect(uids.length).toBe(101); }); });