mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
939 lines
27 KiB
TypeScript
939 lines
27 KiB
TypeScript
/**
|
|
* 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<typeof import('../../src/storage/repo-manager.js')>();
|
|
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<typeof import('../../src/storage/git.js')>();
|
|
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<string, any[]>,
|
|
) {
|
|
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<LocalBackend> {
|
|
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<string, unknown>]
|
|
>;
|
|
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);
|
|
});
|
|
});
|