Skip to content

Commit 90c28f2

Browse files
prosdevclaude
andcommitted
feat(core): build and persist reverse callee index in graph v2
Add reverse callee index that maps callee names to their callers. Built at index time from the same scan data as the dependency graph, persisted inside dependency-graph.json as v2 format (single atomic write prevents drift). Compound keys (file:name) for TS, bare names for tree-sitter languages. Secondary name index for O(1) bare-name lookups. Incremental updates via updateReverseIndexIncremental. - New module: packages/core/src/map/reverse-index.ts - CachedGraph v1→v2 (backward compatible — v1 loads with null reverse) - serializeGraph/deserializeGraph updated for v2 - loadOrBuildGraph returns { graph, reverseIndex } - All callers updated: map/index.ts, cli/refs.ts, refs-adapter.ts, incremental-indexer.ts - 25 new tests in reverse-index.test.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5e8b6a5 commit 90c28f2

10 files changed

Lines changed: 714 additions & 43 deletions

File tree

packages/cli/src/commands/refs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export const refsCommand = new Command('refs')
7070
// Handle --depends-on
7171
if (options.dependsOn) {
7272
spinner.text = `Tracing path: ${name}${options.dependsOn}`;
73-
const graph = await loadOrBuildGraph(filePaths.dependencyGraph, async () =>
73+
const { graph } = await loadOrBuildGraph(filePaths.dependencyGraph, async () =>
7474
indexer.getAll({ limit: 50000 })
7575
);
7676
const sourceFile = (target.metadata.path as string) || '';

packages/core/src/indexer/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as path from 'node:path';
1111
import type { Logger } from '@prosdevlab/kero';
1212
import type { EventBus } from '../events/types.js';
1313
import { buildDependencyGraph, serializeGraph } from '../map/graph';
14+
import { buildReverseCalleeIndex } from '../map/reverse-index';
1415
import { scanRepository } from '../scanner';
1516
import { getStorageFilePaths } from '../storage/path';
1617
import type { EmbeddingDocument, LinearMergeResult, SearchOptions, SearchResult } from '../vector';
@@ -196,10 +197,14 @@ export class RepositoryIndexer {
196197
metadata: d.metadata,
197198
}));
198199
const graph = buildDependencyGraph(graphDocs);
200+
const reverseIndex = buildReverseCalleeIndex(graphDocs);
199201
const storagePath = path.dirname(this.config.vectorStorePath);
200202
const graphPath = getStorageFilePaths(storagePath).dependencyGraph;
201-
await fs.writeFile(graphPath, serializeGraph(graph), 'utf-8');
202-
logger?.info({ nodes: graph.size }, 'Dependency graph cached');
203+
await fs.writeFile(graphPath, serializeGraph(graph, reverseIndex), 'utf-8');
204+
logger?.info(
205+
{ nodes: graph.size, reverseIndexKeys: reverseIndex.size },
206+
'Dependency graph + reverse index cached'
207+
);
203208
} catch (graphError) {
204209
// Non-fatal — graph is a performance optimization, not required
205210
logger?.warn({ error: graphError }, 'Failed to cache dependency graph');

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

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -343,34 +343,69 @@ describe('shortestPath', () => {
343343
// ============================================================================
344344

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

351351
const json = serializeGraph(graph);
352-
const restored = deserializeGraph(json);
352+
const result = deserializeGraph(json);
353353

354-
expect(restored).not.toBeNull();
355-
expect(restored!.size).toBe(2);
356-
expect(restored!.get('src/a.ts')).toEqual([
354+
expect(result).not.toBeNull();
355+
expect(result!.graph.size).toBe(2);
356+
expect(result!.graph.get('src/a.ts')).toEqual([
357357
{ target: 'src/b.ts', weight: 1.5 },
358358
{ target: 'src/c.ts', weight: 1 },
359359
]);
360-
expect(restored!.get('src/b.ts')).toEqual([{ target: 'src/c.ts', weight: 2 }]);
360+
expect(result!.graph.get('src/b.ts')).toEqual([{ target: 'src/c.ts', weight: 2 }]);
361361
});
362362

363-
it('should include metadata in serialized JSON', () => {
363+
it('should round-trip graph + reverse index (v2)', () => {
364+
const graph = new Map([['a.ts', [edge('b.ts')]]]);
365+
const reverseIndex = new Map([
366+
['b.ts:funcB', [{ name: 'funcA', file: 'a.ts', line: 5, type: 'function' }]],
367+
]);
368+
369+
const json = serializeGraph(graph, reverseIndex);
370+
const result = deserializeGraph(json);
371+
372+
expect(result!.graph).toEqual(graph);
373+
expect(result!.reverseIndex).toEqual(reverseIndex);
374+
});
375+
376+
it('should serialize as v2', () => {
364377
const graph = new Map<string, WeightedEdge[]>();
365378
graph.set('a', [edge('b')]);
366379

367380
const parsed = JSON.parse(serializeGraph(graph));
368-
expect(parsed.version).toBe(1);
381+
expect(parsed.version).toBe(2);
369382
expect(parsed.nodeCount).toBe(1);
370383
expect(parsed.edgeCount).toBe(1);
371384
expect(parsed.generatedAt).toBeTruthy();
372385
});
373386

387+
it('should accept generatedAt parameter for testability', () => {
388+
const graph = new Map<string, WeightedEdge[]>();
389+
const json = serializeGraph(graph, undefined, '2026-01-01T00:00:00Z');
390+
const parsed = JSON.parse(json);
391+
expect(parsed.generatedAt).toBe('2026-01-01T00:00:00Z');
392+
});
393+
394+
it('should deserialize v1 graph with null reverse index', () => {
395+
const v1Json = JSON.stringify({
396+
version: 1,
397+
generatedAt: '',
398+
nodeCount: 1,
399+
edgeCount: 1,
400+
graph: { 'a.ts': [{ target: 'b.ts', weight: 1 }] },
401+
});
402+
const result = deserializeGraph(v1Json);
403+
404+
expect(result).not.toBeNull();
405+
expect(result!.graph.size).toBe(1);
406+
expect(result!.reverseIndex).toBeNull();
407+
});
408+
374409
it('should return null for invalid JSON', () => {
375410
expect(deserializeGraph('not json')).toBeNull();
376411
});
@@ -388,9 +423,9 @@ describe('serializeGraph / deserializeGraph', () => {
388423
it('should handle empty graph', () => {
389424
const graph = new Map<string, WeightedEdge[]>();
390425
const json = serializeGraph(graph);
391-
const restored = deserializeGraph(json);
392-
expect(restored).not.toBeNull();
393-
expect(restored!.size).toBe(0);
426+
const result = deserializeGraph(json);
427+
expect(result).not.toBeNull();
428+
expect(result!.graph.size).toBe(0);
394429
});
395430
});
396431

@@ -411,8 +446,9 @@ describe('loadOrBuildGraph', () => {
411446
},
412447
];
413448

414-
const graph = await loadOrBuildGraph(undefined, async () => fallbackDocs);
415-
expect(graph.get('src/a.ts')).toBeDefined();
449+
const result = await loadOrBuildGraph(undefined, async () => fallbackDocs);
450+
expect(result.graph.get('src/a.ts')).toBeDefined();
451+
expect(result.reverseIndex).toBeNull(); // fallback doesn't build reverse index
416452
});
417453

418454
it('should call fallback when graphPath file does not exist', async () => {
@@ -427,8 +463,8 @@ describe('loadOrBuildGraph', () => {
427463
},
428464
];
429465

430-
const graph = await loadOrBuildGraph('/nonexistent/path.json', async () => fallbackDocs);
431-
expect(graph.get('src/x.ts')).toBeDefined();
466+
const result = await loadOrBuildGraph('/nonexistent/path.json', async () => fallbackDocs);
467+
expect(result.graph.get('src/x.ts')).toBeDefined();
432468
});
433469
});
434470

0 commit comments

Comments
 (0)