GitNexus/gitnexus/test/unit/trace-bfs.test.ts

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);
});
});