/** * Unit Tests: trace tool — BFS pathfinding, symbol resolution, gap reporting * * Drives the implementation of the `trace` MCP tool via TDD. * Mocks LadybugDB; tests the LocalBackend trace() logic in isolation. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; const { lbugMocks } = vi.hoisted(() => ({ lbugMocks: { initLbug: vi.fn().mockResolvedValue(undefined), executeQuery: vi.fn().mockResolvedValue([]), executeParameterized: vi.fn().mockResolvedValue([]), closeLbug: vi.fn().mockResolvedValue(undefined), isLbugReady: vi.fn().mockReturnValue(true), }, })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, listRegisteredRepos: vi.fn().mockResolvedValue([ { name: 'test-project', path: '/tmp/test-project', storagePath: '/tmp/.gitnexus/test-project', indexedAt: '2024-06-01T12:00:00Z', lastCommit: 'abc123', stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 }, }, ]), cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), findSiblingClones: vi.fn().mockResolvedValue([]), }; }); vi.mock('../../src/core/git-staleness.js', () => ({ checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }), checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }), checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), })); vi.mock('../../src/storage/git.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getGitRoot: vi.fn().mockReturnValue(null) }; }); vi.mock('../../src/core/search/bm25-index.js', () => ({ searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }), })); vi.mock('../../src/mcp/core/embedder.js', () => ({ embedQuery: vi.fn().mockResolvedValue([]), getEmbeddingDims: vi.fn().mockReturnValue(384), })); import { LocalBackend } from '../../src/mcp/local/local-backend.js'; import { executeParameterized } from '../../src/mcp/core/lbug-adapter.js'; // ─── Helpers ───────────────────────────────────────────────────────── const SYMBOL_A = { id: 'func:A', name: 'A', type: 'Function', filePath: 'src/a.ts', startLine: 1, endLine: 10, }; const SYMBOL_B = { id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, endLine: 5, }; function makeResolveMock( fromRows: any[], toRows: any[], bfsRowsByFrontier?: Record, ) { const bfsMap = bfsRowsByFrontier ?? {}; // `async` matters: the real executeParameterized returns a promise, and // callers chain `.catch()` on it directly. A mock returning a bare array made // that chain a TypeError, so the sync shape was never a faithful stand-in. return async (_db: string, _query: string, params: any) => { // UID lookup keys on params.uid — the real query is `MATCH (n {id: $uid})`, // so matching on query text ('WHERE n.id = $uid') never fired. if (params.uid) { if (fromRows.length === 1 && fromRows[0].id === params.uid) return fromRows; if (toRows.length === 1 && toRows[0].id === params.uid) return toRows; } if (params.symName !== undefined) { if (params.symName === fromRows[0]?.name && fromRows.length > 0) return fromRows; if (params.symName === toRows[0]?.name && toRows.length > 0) return toRows; } if (params.frontierIds) { // The real per-level query fetches neighbors for ALL frontier ids at // once; concatenate so a multi-node frontier is modelled faithfully. const rows: any[] = []; for (const frontierId of params.frontierIds) { if (bfsMap[frontierId]) rows.push(...bfsMap[frontierId]); } return rows; } return []; }; } async function makeBackend(): Promise { const b = new LocalBackend(); await b.init(); return b; } describe('trace: dispatch', () => { let backend: LocalBackend; beforeEach(async () => { vi.clearAllMocks(); backend = await makeBackend(); }); it('dispatches trace tool without throwing', async () => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('trace', { from: 'foo', to: 'bar' }); expect(result).toBeDefined(); expect(result.status).toBe('not_found'); }); it('returns not_found when source symbol does not exist', async () => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('trace', { from: 'nonexistent', to: 'bar' }); expect(result.status).toBe('not_found'); expect(result.error).toContain('nonexistent'); }); it('returns ambiguous with candidates when source has multiple matches', async () => { (executeParameterized as any).mockResolvedValue([ { id: 'func:login:1', name: 'login', type: 'Function', filePath: 'src/auth.ts', startLine: 1, endLine: 10, }, { id: 'func:login:2', name: 'login', type: 'Function', filePath: 'src/admin.ts', startLine: 5, endLine: 15, }, ]); const result = await backend.callTool('trace', { from: 'login', to: 'bar' }); expect(result.status).toBe('ambiguous'); expect(result.role).toBe('from'); expect(result.candidates).toHaveLength(2); }); it('returns ambiguous with candidates when target has multiple matches', async () => { (executeParameterized as any).mockResolvedValueOnce([SYMBOL_A]).mockResolvedValue([ { id: 'func:db:1', name: 'db', type: 'Function', filePath: 'src/db.ts', startLine: 1, endLine: 10, }, { id: 'func:db:2', name: 'db', type: 'Function', filePath: 'src/db2.ts', startLine: 1, endLine: 10, }, ]); const result = await backend.callTool('trace', { from: 'A', to: 'db' }); expect(result.status).toBe('ambiguous'); expect(result.role).toBe('to'); expect(result.candidates).toHaveLength(2); }); it('accepts file as an MCP alias for from_file', async () => { (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_B], { [SYMBOL_A.id]: [] }), ); await backend.callTool('trace', { from: 'A', to: 'B', file: 'src/a.ts' }); expect((executeParameterized as any).mock.calls[0][2]).toMatchObject({ symName: 'A', filePath: 'src/a.ts', }); }); it('rejects conflicting file and from_file MCP aliases', async () => { const result = await backend.callTool('trace', { from: 'A', to: 'B', file: 'src/a.ts', from_file: 'src/other.ts', }); expect(result).toEqual({ error: 'Conflicting MCP parameters for trace.from_file: from_file, file must agree.', }); expect(executeParameterized).not.toHaveBeenCalled(); }); }); // ─── Group 2: BFS Core ────────────────────────────────────────────── describe('trace: BFS core', () => { let backend: LocalBackend; beforeEach(async () => { vi.clearAllMocks(); backend = await makeBackend(); }); it('returns 0-hop path when from and to are the same symbol', async () => { (executeParameterized as any).mockResolvedValue([SYMBOL_A]); const result = await backend.callTool('trace', { from: 'A', to: 'A' }); expect(result.status).toBe('ok'); expect(result.hopCount).toBe(0); expect(result.hops).toHaveLength(1); expect(result.hops[0].name).toBe('A'); expect(result.edges).toHaveLength(0); }); it('finds direct 1-hop path A→B', async () => { (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_B], { 'func:A': [ { sourceId: 'func:A', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'B' }); expect(result.status).toBe('ok'); expect(result.hopCount).toBe(1); expect(result.hops).toHaveLength(2); expect(result.hops[0].name).toBe('A'); expect(result.hops[1].name).toBe('B'); expect(result.edges).toHaveLength(1); expect(result.edges[0].relType).toBe('CALLS'); expect(result.edges[0].confidence).toBe(1.0); }); it('finds 2-hop path A→C→B', async () => { (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_B], { 'func:A': [ { sourceId: 'func:A', id: 'func:C', name: 'C', type: 'Function', filePath: 'src/c.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], 'func:C': [ { sourceId: 'func:C', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 0.95, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'B' }); expect(result.status).toBe('ok'); expect(result.hopCount).toBe(2); expect(result.hops).toHaveLength(3); expect(result.hops.map((h: any) => h.name)).toEqual(['A', 'C', 'B']); expect(result.edges[0].confidence).toBe(1.0); expect(result.edges[1].confidence).toBe(0.95); }); it('reports furthest reachable node when no path exists', async () => { const SYMBOL_X = { id: 'func:X', name: 'X', type: 'Function', filePath: 'src/x.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_X], { 'func:A': [ { sourceId: 'func:A', id: 'func:C', name: 'C', type: 'Function', filePath: 'src/c.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'X' }); expect(result.status).toBe('no_path'); expect(result.furthest).toBeDefined(); expect(result.furthest.name).toBe('C'); expect(result.furthest.depth).toBe(1); expect(result.suggestion).toBeDefined(); }); it('handles cycles without infinite loop', async () => { const SYMBOL_X = { id: 'func:X', name: 'X', type: 'Function', filePath: 'src/x.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_X], { 'func:A': [ { sourceId: 'func:A', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, { sourceId: 'func:A', id: 'func:A', name: 'A', type: 'Function', filePath: 'src/a.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], 'func:B': [ { sourceId: 'func:B', id: 'func:A', name: 'A', type: 'Function', filePath: 'src/a.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'X' }); expect(result.status).toBe('no_path'); }, 5000); it('respects maxDepth limit', async () => { const SYMBOL_E = { id: 'func:E', name: 'E', type: 'Function', filePath: 'src/e.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_E], { 'func:A': [ { sourceId: 'func:A', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], 'func:B': [ { sourceId: 'func:B', id: 'func:C', name: 'C', type: 'Function', filePath: 'src/c.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], 'func:C': [ { sourceId: 'func:C', id: 'func:D', name: 'D', type: 'Function', filePath: 'src/d.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], 'func:D': [ { sourceId: 'func:D', id: 'func:E', name: 'E', type: 'Function', filePath: 'src/e.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'E', maxDepth: 2 }); expect(result.status).toBe('no_path'); }); it('treats maxDepth 0 / negative / NaN as the default rather than a false no_path', async () => { const oneHop = { 'func:A': [ { sourceId: 'func:A', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }; for (const badDepth of [0, -5, NaN]) { (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_B], oneHop), ); const result = await backend.callTool('trace', { from: 'A', to: 'B', maxDepth: badDepth }); expect(result.status, `maxDepth=${badDepth}`).toBe('ok'); expect(result.hopCount, `maxDepth=${badDepth}`).toBe(1); } }); it('reaches a target that lives in a test file even when includeTests is false', async () => { const SYMBOL_T = { id: 'func:T', name: 'T', type: 'Function', filePath: 'src/t.test.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_T], { 'func:A': [ { sourceId: 'func:A', id: 'func:T', name: 'T', type: 'Function', filePath: 'src/t.test.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'T' }); expect(result.status).toBe('ok'); expect(result.hopCount).toBe(1); expect(result.hops[1].name).toBe('T'); }); it('still filters a non-target test-file hop when includeTests is false', async () => { const graph = { 'func:A': [ { sourceId: 'func:A', id: 'func:M', name: 'M', type: 'Function', filePath: 'src/m.test.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], 'func:M': [ { sourceId: 'func:M', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }; (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_B], graph), ); const filtered = await backend.callTool('trace', { from: 'A', to: 'B' }); expect(filtered.status).toBe('no_path'); (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_B], graph), ); const included = await backend.callTool('trace', { from: 'A', to: 'B', includeTests: true }); expect(included.status).toBe('ok'); expect(included.hops.map((h: any) => h.name)).toEqual(['A', 'M', 'B']); }); it('caps the per-level query with a LIMIT and does not truncate a normal trace', async () => { (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_B], { 'func:A': [ { sourceId: 'func:A', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'B' }); expect(result.status).toBe('ok'); expect(result.truncated).toBeUndefined(); const bfsQueries = ((executeParameterized as any).mock.calls as Array<[string, string, any]>) .map(([, cypher]) => cypher) .filter((c) => c.includes('r:CodeRelation')); expect(bfsQueries.length).toBeGreaterThan(0); expect(bfsQueries.every((c) => /LIMIT\s+\d+/.test(c))).toBe(true); }); it('flags truncated when a frontier level hits the per-node row cap', async () => { // _traceImpl caps a single-node frontier at PER_NODE_FANOUT_CAP (200) rows; // returning exactly that many (none being the target) trips the cap. const ROW_CAP = 200; const hubRows = Array.from({ length: ROW_CAP }, (_, i) => ({ sourceId: 'func:A', id: `func:N${i}`, name: `N${i}`, type: 'Function', filePath: 'src/n.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, })); const SYMBOL_Z = { id: 'func:Z', name: 'Z', type: 'Function', filePath: 'src/z.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_Z], { 'func:A': hubRows }), ); const result = await backend.callTool('trace', { from: 'A', to: 'Z' }); expect(result.status).toBe('no_path'); expect(result.truncated).toBe(true); }); it('returns status:error for a non-string from/to instead of a low-level TypeError', async () => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('trace', { from: 42 as any, to: 'realSymbol' }); expect(result.status).toBe('error'); expect(result.error).toMatch(/must be strings/); }); it('returns status:error with a suggestion when the BFS query throws', async () => { (executeParameterized as any).mockImplementation( async (_db: string, _q: string, params: any) => { if (params.frontierIds) throw new Error('boom: graph exploded'); if (params.symName === 'A') return [SYMBOL_A]; if (params.symName === 'B') return [SYMBOL_B]; return []; }, ); const result = await backend.callTool('trace', { from: 'A', to: 'B' }); expect(result.status).toBe('error'); expect(result.error).toContain('boom'); expect(result.suggestion).toBeDefined(); }); it('resolves from_uid/to_uid without name-based lookup', async () => { (executeParameterized as any).mockImplementation( async (_db: string, query: string, params: any) => { if (params.uid === 'uid:from') return [ { id: 'uid:from', name: 'A', type: 'Function', filePath: 'src/a.ts', startLine: 1, endLine: 10, }, ]; if (params.uid === 'uid:to') return [ { id: 'uid:to', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, endLine: 5, }, ]; if (params.frontierIds?.includes('uid:from')) { return [ { sourceId: 'uid:from', id: 'uid:to', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ]; } return []; }, ); const result = await backend.callTool('trace', { from_uid: 'uid:from', to_uid: 'uid:to' }); expect(result.status).toBe('ok'); expect(result.hopCount).toBe(1); const calls = (executeParameterized as any).mock.calls as Array< [string, string, Record] >; for (const [, cypher] of calls) { expect(cypher).not.toMatch(/WHERE n\.name = \$symName/); } }); it('finds a shortest path that runs through the second node of a multi-node frontier', async () => { // A→B→E (dead end) and A→C→D (target). The path is only reachable via the // second frontier node (C); a mock that returned just the first frontier // node's neighbors would (wrongly) report no_path. const SYMBOL_D = { id: 'func:D', name: 'D', type: 'Function', filePath: 'src/d.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_D], { 'func:A': [ { sourceId: 'func:A', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, { sourceId: 'func:A', id: 'func:C', name: 'C', type: 'Function', filePath: 'src/c.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], 'func:B': [ { sourceId: 'func:B', id: 'func:E', name: 'E', type: 'Function', filePath: 'src/e.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], 'func:C': [ { sourceId: 'func:C', id: 'func:D', name: 'D', type: 'Function', filePath: 'src/d.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'D' }); expect(result.status).toBe('ok'); expect(result.hopCount).toBe(2); expect(result.hops.map((h: any) => h.name)).toEqual(['A', 'C', 'D']); }); it('falls back to the relation-type confidence when stored confidence is 0', async () => { (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_A], [SYMBOL_B], { 'func:A': [ { sourceId: 'func:A', id: 'func:B', name: 'B', type: 'Function', filePath: 'src/b.ts', startLine: 1, edgeType: 'CALLS', confidence: 0, }, ], }), ); const result = await backend.callTool('trace', { from: 'A', to: 'B' }); expect(result.status).toBe('ok'); expect(result.edges[0].confidence).toBe(0.9); // CALLS confidence floor }); it('traverses HAS_METHOD edges and reports a mixed per-hop edge-type chain', async () => { const SYMBOL_CLASS = { id: 'class:K', name: 'K', type: 'Class', filePath: 'src/k.ts', startLine: 1, endLine: 20, }; const SYMBOL_TGT = { id: 'func:T2', name: 'T2', type: 'Function', filePath: 'src/t2.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation( makeResolveMock([SYMBOL_CLASS], [SYMBOL_TGT], { 'class:K': [ { sourceId: 'class:K', id: 'func:m', name: 'm', type: 'Method', filePath: 'src/k.ts', startLine: 5, edgeType: 'HAS_METHOD', confidence: 0.95, }, ], 'func:m': [ { sourceId: 'func:m', id: 'func:T2', name: 'T2', type: 'Function', filePath: 'src/t2.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ], }), ); const result = await backend.callTool('trace', { from: 'K', to: 'T2' }); expect(result.status).toBe('ok'); expect(result.hopCount).toBe(2); expect(result.edges.map((e: any) => e.relType)).toEqual(['HAS_METHOD', 'CALLS']); expect(result.hops.map((h: any) => h.name)).toEqual(['K', 'm', 'T2']); }); it('returns no_path with furthest null when the source has no outgoing edges', async () => { const SYMBOL_X = { id: 'func:X', name: 'X', type: 'Function', filePath: 'src/x.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation(makeResolveMock([SYMBOL_A], [SYMBOL_X], {})); const result = await backend.callTool('trace', { from: 'A', to: 'X' }); expect(result.status).toBe('no_path'); expect(result.furthest).toBeNull(); }); it('uses from_file to disambiguate same-named symbols', async () => { const helperA = { id: 'func:helperA', name: 'helper', type: 'Function', filePath: 'src/a.ts', startLine: 1, endLine: 5, }; const target = { id: 'func:target', name: 'target', type: 'Function', filePath: 'src/t.ts', startLine: 1, endLine: 5, }; (executeParameterized as any).mockImplementation( async (_db: string, _q: string, params: any) => { if (params.symName === 'helper' && params.filePath === 'src/a.ts') return [helperA]; if (params.symName === 'target') return [target]; if (params.frontierIds?.includes('func:helperA')) { return [ { sourceId: 'func:helperA', id: 'func:target', name: 'target', type: 'Function', filePath: 'src/t.ts', startLine: 1, edgeType: 'CALLS', confidence: 1.0, }, ]; } return []; }, ); const result = await backend.callTool('trace', { from: 'helper', from_file: 'src/a.ts', to: 'target', }); expect(result.status).toBe('ok'); expect(result.from.filePath).toBe('src/a.ts'); expect(result.hopCount).toBe(1); }); });