Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reverse-callee-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prosdevlab/dev-agent": patch
---

Add reverse callee index to dev_refs — callers now work. Previously "No callers found" for every function because caller detection relied on semantic search (returned similar concepts, not call sites). Now uses a persisted reverse index with 4,000+ caller entries, compound keys for O(1) lookup, and class-level aggregation.
45 changes: 21 additions & 24 deletions packages/cli/src/commands/refs.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import * as path from 'node:path';
import {
buildNameIndex,
ensureStorageDirectory,
getStorageFilePaths,
getStoragePath,
loadOrBuildGraph,
lookupCallers,
lookupClassCallers,
RepositoryIndexer,
type SearchResult,
shortestPath,
Expand Down Expand Up @@ -70,7 +73,7 @@ export const refsCommand = new Command('refs')
// Handle --depends-on
if (options.dependsOn) {
spinner.text = `Tracing path: ${name} → ${options.dependsOn}`;
const graph = await loadOrBuildGraph(filePaths.dependencyGraph, async () =>
const { graph } = await loadOrBuildGraph(filePaths.dependencyGraph, async () =>
indexer.getAll({ limit: 50000 })
);
const sourceFile = (target.metadata.path as string) || '';
Expand Down Expand Up @@ -109,32 +112,26 @@ export const refsCommand = new Command('refs')
callees = (rawCallees || []).slice(0, limit);
}

// Get callers
// Get callers from reverse index
const callers: Array<{ name: string; file?: string; line: number; type?: string }> = [];
if (direction === 'callers' || direction === 'both') {
const targetName = target.metadata.name as string;
const candidates = await indexer.search(targetName, { limit: 100 });

for (const candidate of candidates) {
if (candidate.id === target.id) continue;
const candidateCallees = candidate.metadata.callees as CalleeInfo[] | undefined;
if (!candidateCallees) continue;

const callsTarget = candidateCallees.some(
(c) =>
c.name === targetName ||
c.name.endsWith(`.${targetName}`) ||
targetName.endsWith(`.${c.name}`)
);
const { reverseIndex } = await loadOrBuildGraph(filePaths.dependencyGraph, async () =>
indexer.getAll({ limit: 50000 })
);

if (reverseIndex) {
const nameIndex = buildNameIndex(reverseIndex);
const targetName = (target.metadata.name as string) || '';
const targetFile = (target.metadata.path as string) || '';
const targetType = target.metadata.type as string;

const found =
targetType === 'class'
? lookupClassCallers(reverseIndex, nameIndex, targetName, targetFile, limit)
: lookupCallers(reverseIndex, nameIndex, targetName, targetFile, limit);

if (callsTarget) {
callers.push({
name: (candidate.metadata.name as string) || 'unknown',
file: candidate.metadata.path as string,
line: (candidate.metadata.startLine as number) || 0,
type: candidate.metadata.type as string,
});
if (callers.length >= limit) break;
for (const c of found) {
callers.push({ name: c.name, file: c.file, line: c.line, type: c.type });
}
}
}
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/indexer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as path from 'node:path';
import type { Logger } from '@prosdevlab/kero';
import type { EventBus } from '../events/types.js';
import { buildDependencyGraph, serializeGraph } from '../map/graph';
import { buildReverseCalleeIndex } from '../map/reverse-index';
import { scanRepository } from '../scanner';
import { getStorageFilePaths } from '../storage/path';
import type { EmbeddingDocument, LinearMergeResult, SearchOptions, SearchResult } from '../vector';
Expand Down Expand Up @@ -196,10 +197,14 @@ export class RepositoryIndexer {
metadata: d.metadata,
}));
const graph = buildDependencyGraph(graphDocs);
const reverseIndex = buildReverseCalleeIndex(graphDocs);
const storagePath = path.dirname(this.config.vectorStorePath);
const graphPath = getStorageFilePaths(storagePath).dependencyGraph;
await fs.writeFile(graphPath, serializeGraph(graph), 'utf-8');
logger?.info({ nodes: graph.size }, 'Dependency graph cached');
await fs.writeFile(graphPath, serializeGraph(graph, reverseIndex), 'utf-8');
logger?.info(
{ nodes: graph.size, reverseIndexKeys: reverseIndex.size },
'Dependency graph + reverse index cached'
);
} catch (graphError) {
// Non-fatal — graph is a performance optimization, not required
logger?.warn({ error: graphError }, 'Failed to cache dependency graph');
Expand Down
68 changes: 53 additions & 15 deletions packages/core/src/map/__tests__/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,34 +343,69 @@ describe('shortestPath', () => {
// ============================================================================

describe('serializeGraph / deserializeGraph', () => {
it('should round-trip correctly', () => {
it('should round-trip graph correctly', () => {
const graph = new Map<string, WeightedEdge[]>();
graph.set('src/a.ts', [edge('src/b.ts', 1.5), edge('src/c.ts', 1)]);
graph.set('src/b.ts', [edge('src/c.ts', 2)]);

const json = serializeGraph(graph);
const restored = deserializeGraph(json);
const result = deserializeGraph(json);

expect(restored).not.toBeNull();
expect(restored!.size).toBe(2);
expect(restored!.get('src/a.ts')).toEqual([
expect(result).not.toBeNull();
expect(result!.graph.size).toBe(2);
expect(result!.graph.get('src/a.ts')).toEqual([
{ target: 'src/b.ts', weight: 1.5 },
{ target: 'src/c.ts', weight: 1 },
]);
expect(restored!.get('src/b.ts')).toEqual([{ target: 'src/c.ts', weight: 2 }]);
expect(result!.graph.get('src/b.ts')).toEqual([{ target: 'src/c.ts', weight: 2 }]);
});

it('should include metadata in serialized JSON', () => {
it('should round-trip graph + reverse index (v2)', () => {
const graph = new Map([['a.ts', [edge('b.ts')]]]);
const reverseIndex = new Map([
['b.ts:funcB', [{ name: 'funcA', file: 'a.ts', line: 5, type: 'function' }]],
]);

const json = serializeGraph(graph, reverseIndex);
const result = deserializeGraph(json);

expect(result!.graph).toEqual(graph);
expect(result!.reverseIndex).toEqual(reverseIndex);
});

it('should serialize as v2', () => {
const graph = new Map<string, WeightedEdge[]>();
graph.set('a', [edge('b')]);

const parsed = JSON.parse(serializeGraph(graph));
expect(parsed.version).toBe(1);
expect(parsed.version).toBe(2);
expect(parsed.nodeCount).toBe(1);
expect(parsed.edgeCount).toBe(1);
expect(parsed.generatedAt).toBeTruthy();
});

it('should accept generatedAt parameter for testability', () => {
const graph = new Map<string, WeightedEdge[]>();
const json = serializeGraph(graph, undefined, '2026-01-01T00:00:00Z');
const parsed = JSON.parse(json);
expect(parsed.generatedAt).toBe('2026-01-01T00:00:00Z');
});

it('should deserialize v1 graph with null reverse index', () => {
const v1Json = JSON.stringify({
version: 1,
generatedAt: '',
nodeCount: 1,
edgeCount: 1,
graph: { 'a.ts': [{ target: 'b.ts', weight: 1 }] },
});
const result = deserializeGraph(v1Json);

expect(result).not.toBeNull();
expect(result!.graph.size).toBe(1);
expect(result!.reverseIndex).toBeNull();
});

it('should return null for invalid JSON', () => {
expect(deserializeGraph('not json')).toBeNull();
});
Expand All @@ -388,9 +423,9 @@ describe('serializeGraph / deserializeGraph', () => {
it('should handle empty graph', () => {
const graph = new Map<string, WeightedEdge[]>();
const json = serializeGraph(graph);
const restored = deserializeGraph(json);
expect(restored).not.toBeNull();
expect(restored!.size).toBe(0);
const result = deserializeGraph(json);
expect(result).not.toBeNull();
expect(result!.graph.size).toBe(0);
});
});

Expand All @@ -411,8 +446,11 @@ describe('loadOrBuildGraph', () => {
},
];

const graph = await loadOrBuildGraph(undefined, async () => fallbackDocs);
expect(graph.get('src/a.ts')).toBeDefined();
const result = await loadOrBuildGraph(undefined, async () => fallbackDocs);
expect(result.graph.get('src/a.ts')).toBeDefined();
// Fallback builds reverse index from the same docs
expect(result.reverseIndex).not.toBeNull();
expect(result.reverseIndex!.get('src/b.ts:foo')).toBeDefined();
});

it('should call fallback when graphPath file does not exist', async () => {
Expand All @@ -427,8 +465,8 @@ describe('loadOrBuildGraph', () => {
},
];

const graph = await loadOrBuildGraph('/nonexistent/path.json', async () => fallbackDocs);
expect(graph.get('src/x.ts')).toBeDefined();
const result = await loadOrBuildGraph('/nonexistent/path.json', async () => fallbackDocs);
expect(result.graph.get('src/x.ts')).toBeDefined();
});
});

Expand Down
Loading