Skip to content

Commit 6b86282

Browse files
prosdevclaude
andcommitted
feat(mcp): wire reverse callee index into CLI refs and MCP adapter
Replace broken semantic-search caller detection with reverse index lookups. Both CLI refs and MCP refs adapter now use lookupCallers/ lookupClassCallers from core instead of searching for the target name and scanning 100 candidates' callees. - CLI refs: loads reverse index via loadOrBuildGraph, uses lookupCallers - MCP refs adapter: caches reverse + name index alongside graph (60s TTL) - getCallersFromIndex replaces getCallers (no more SearchService call) - loadOrBuildGraph fallback now builds reverse index from docs - getDependencyGraph handles missing indexer gracefully - Updated refs-adapter tests with mock indexer for reverse index Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 90c28f2 commit 6b86282

5 files changed

Lines changed: 94 additions & 71 deletions

File tree

packages/cli/src/commands/refs.ts

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import * as path from 'node:path';
22
import {
3+
buildNameIndex,
34
ensureStorageDirectory,
45
getStorageFilePaths,
56
getStoragePath,
67
loadOrBuildGraph,
8+
lookupCallers,
9+
lookupClassCallers,
710
RepositoryIndexer,
811
type SearchResult,
912
shortestPath,
@@ -109,32 +112,26 @@ export const refsCommand = new Command('refs')
109112
callees = (rawCallees || []).slice(0, limit);
110113
}
111114

112-
// Get callers
115+
// Get callers from reverse index
113116
const callers: Array<{ name: string; file?: string; line: number; type?: string }> = [];
114117
if (direction === 'callers' || direction === 'both') {
115-
const targetName = target.metadata.name as string;
116-
const candidates = await indexer.search(targetName, { limit: 100 });
117-
118-
for (const candidate of candidates) {
119-
if (candidate.id === target.id) continue;
120-
const candidateCallees = candidate.metadata.callees as CalleeInfo[] | undefined;
121-
if (!candidateCallees) continue;
122-
123-
const callsTarget = candidateCallees.some(
124-
(c) =>
125-
c.name === targetName ||
126-
c.name.endsWith(`.${targetName}`) ||
127-
targetName.endsWith(`.${c.name}`)
128-
);
118+
const { reverseIndex } = await loadOrBuildGraph(filePaths.dependencyGraph, async () =>
119+
indexer.getAll({ limit: 50000 })
120+
);
121+
122+
if (reverseIndex) {
123+
const nameIndex = buildNameIndex(reverseIndex);
124+
const targetName = (target.metadata.name as string) || '';
125+
const targetFile = (target.metadata.path as string) || '';
126+
const targetType = target.metadata.type as string;
127+
128+
const found =
129+
targetType === 'class'
130+
? lookupClassCallers(reverseIndex, nameIndex, targetName, targetFile, limit)
131+
: lookupCallers(reverseIndex, nameIndex, targetName, targetFile, limit);
129132

130-
if (callsTarget) {
131-
callers.push({
132-
name: (candidate.metadata.name as string) || 'unknown',
133-
file: candidate.metadata.path as string,
134-
line: (candidate.metadata.startLine as number) || 0,
135-
type: candidate.metadata.type as string,
136-
});
137-
if (callers.length >= limit) break;
133+
for (const c of found) {
134+
callers.push({ name: c.name, file: c.file, line: c.line, type: c.type });
138135
}
139136
}
140137
}

packages/core/src/map/__tests__/graph.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,9 @@ describe('loadOrBuildGraph', () => {
448448

449449
const result = await loadOrBuildGraph(undefined, async () => fallbackDocs);
450450
expect(result.graph.get('src/a.ts')).toBeDefined();
451-
expect(result.reverseIndex).toBeNull(); // fallback doesn't build reverse index
451+
// Fallback builds reverse index from the same docs
452+
expect(result.reverseIndex).not.toBeNull();
453+
expect(result.reverseIndex!.get('src/b.ts:foo')).toBeDefined();
452454
});
453455

454456
it('should call fallback when graphPath file does not exist', async () => {

packages/core/src/map/graph.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import * as fs from 'node:fs/promises';
1515
import type { SearchResult } from '../vector/types';
16+
import { buildReverseCalleeIndex } from './reverse-index';
1617
import type { CallerEntry } from './types';
1718

1819
// ============================================================================
@@ -377,7 +378,10 @@ export async function loadOrBuildGraph(
377378
}
378379

379380
const docs = await fallbackDocs();
380-
return { graph: buildDependencyGraph(docs), reverseIndex: null };
381+
return {
382+
graph: buildDependencyGraph(docs),
383+
reverseIndex: docs.length > 0 ? buildReverseCalleeIndex(docs) : null,
384+
};
381385
}
382386

383387
// ============================================================================

packages/mcp-server/src/adapters/__tests__/refs-adapter.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,17 @@ describe('RefsAdapter', () => {
7474
search: vi.fn().mockResolvedValue(mockSearchResults),
7575
} as unknown as SearchService;
7676

77-
// Create adapter
77+
// Create mock indexer for reverse index building
78+
const mockIndexer = {
79+
getAll: vi.fn().mockResolvedValue(mockSearchResults),
80+
initialize: vi.fn(),
81+
close: vi.fn(),
82+
};
83+
84+
// Create adapter with indexer so reverse index can be built
7885
adapter = new RefsAdapter({
7986
searchService: mockSearchService,
87+
indexer: mockIndexer as any,
8088
defaultLimit: 20,
8189
});
8290

@@ -349,8 +357,14 @@ describe('RefsAdapter', () => {
349357
});
350358

351359
it('should return error when indexer is not available', async () => {
352-
// The base adapter (no indexer) should fail for dependsOn
353-
const result = await adapter.execute(
360+
// Create an adapter without indexer — dependsOn requires it
361+
const adapterNoIndexer = new RefsAdapter({
362+
searchService: mockSearchService,
363+
defaultLimit: 20,
364+
});
365+
await adapterNoIndexer.initialize(context);
366+
367+
const result = await adapterNoIndexer.execute(
354368
{ name: 'createPlan', dependsOn: 'src/api.ts' },
355369
execContext
356370
);

packages/mcp-server/src/adapters/built-in/refs-adapter.ts

Lines changed: 49 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@
55

66
import type {
77
CalleeInfo,
8+
CallerEntry,
89
RepositoryIndexer,
910
SearchResult,
1011
SearchService,
1112
} from '@prosdevlab/dev-agent-core';
12-
import { loadOrBuildGraph, shortestPath } from '@prosdevlab/dev-agent-core';
13+
import {
14+
buildNameIndex,
15+
loadOrBuildGraph,
16+
lookupCallers,
17+
lookupClassCallers,
18+
shortestPath,
19+
} from '@prosdevlab/dev-agent-core';
1320
import { estimateTokensForText, startTimer } from '../../formatters/utils';
1421
import { RefsArgsSchema } from '../../schemas/index.js';
1522
import { ToolAdapter } from '../tool-adapter';
@@ -78,6 +85,8 @@ export class RefsAdapter extends ToolAdapter {
7885
private indexer?: RepositoryIndexer;
7986
private graphPath?: string;
8087
private cachedGraph?: Map<string, import('@prosdevlab/dev-agent-core').WeightedEdge[]>;
88+
private cachedReverseIndex: Map<string, CallerEntry[]> | null = null;
89+
private cachedNameIndex: Map<string, string[]> | null = null;
8190
private cachedGraphTime = 0;
8291

8392
constructor(config: RefsAdapterConfig) {
@@ -110,8 +119,9 @@ export class RefsAdapter extends ToolAdapter {
110119
}
111120

112121
const result = await loadOrBuildGraph(this.graphPath, async () => {
122+
if (!this.indexer) return [];
113123
const DOC_LIMIT = 50_000;
114-
const allDocs = await this.indexer!.getAll({ limit: DOC_LIMIT });
124+
const allDocs = await this.indexer.getAll({ limit: DOC_LIMIT });
115125
if (allDocs.length >= DOC_LIMIT) {
116126
console.error(
117127
`[dev-agent] Warning: dependency graph hit ${DOC_LIMIT} doc limit. Results may be incomplete.`
@@ -120,6 +130,8 @@ export class RefsAdapter extends ToolAdapter {
120130
return allDocs;
121131
});
122132
this.cachedGraph = result.graph;
133+
this.cachedReverseIndex = result.reverseIndex;
134+
this.cachedNameIndex = result.reverseIndex ? buildNameIndex(result.reverseIndex) : null;
123135
this.cachedGraphTime = Date.now();
124136
return this.cachedGraph;
125137
}
@@ -251,7 +263,9 @@ export class RefsAdapter extends ToolAdapter {
251263

252264
// Get callers if requested
253265
if (direction === 'callers' || direction === 'both') {
254-
result.callers = await this.getCallers(target, limit);
266+
// Ensure graph (and reverse index) is loaded before looking up callers
267+
await this.getDependencyGraph();
268+
result.callers = this.getCallersFromIndex(target, limit);
255269
}
256270

257271
const content = this.formatOutput(result, direction);
@@ -322,47 +336,39 @@ export class RefsAdapter extends ToolAdapter {
322336
}
323337

324338
/**
325-
* Find callers by searching all indexed components for callees that reference the target
339+
* Find callers using the reverse callee index.
340+
* Falls back to empty results if no reverse index is available (v1 graph).
326341
*/
327-
private async getCallers(target: SearchResult, limit: number): Promise<RefResult[]> {
328-
const targetName = target.metadata.name;
329-
if (!targetName) return [];
330-
331-
// Search for components that might call this target
332-
// We search broadly and then filter by callees
333-
const candidates = await this.searchService.search(targetName, { limit: 100 });
334-
335-
const callers: RefResult[] = [];
336-
337-
for (const candidate of candidates) {
338-
// Skip the target itself
339-
if (candidate.id === target.id) continue;
340-
341-
const callees = candidate.metadata.callees as CalleeInfo[] | undefined;
342-
if (!callees) continue;
343-
344-
// Check if any callee matches our target
345-
const callsTarget = callees.some(
346-
(c) =>
347-
c.name === targetName ||
348-
c.name.endsWith(`.${targetName}`) ||
349-
targetName.endsWith(`.${c.name}`)
350-
);
351-
352-
if (callsTarget) {
353-
callers.push({
354-
name: candidate.metadata.name || 'unknown',
355-
file: candidate.metadata.path,
356-
line: candidate.metadata.startLine || 0,
357-
type: candidate.metadata.type as string,
358-
snippet: candidate.metadata.signature as string | undefined,
359-
});
360-
361-
if (callers.length >= limit) break;
362-
}
363-
}
364-
365-
return callers;
342+
private getCallersFromIndex(target: SearchResult, limit: number): RefResult[] {
343+
if (!this.cachedReverseIndex || !this.cachedNameIndex) return [];
344+
345+
const targetName = (target.metadata.name as string) || '';
346+
const targetFile = (target.metadata.path as string) || '';
347+
const targetType = target.metadata.type as string;
348+
349+
const callers =
350+
targetType === 'class'
351+
? lookupClassCallers(
352+
this.cachedReverseIndex,
353+
this.cachedNameIndex,
354+
targetName,
355+
targetFile,
356+
limit
357+
)
358+
: lookupCallers(
359+
this.cachedReverseIndex,
360+
this.cachedNameIndex,
361+
targetName,
362+
targetFile,
363+
limit
364+
);
365+
366+
return callers.map((c) => ({
367+
name: c.name,
368+
file: c.file,
369+
line: c.line,
370+
type: c.type,
371+
}));
366372
}
367373

368374
/**

0 commit comments

Comments
 (0)