From 90c28f2e871845bec2d07e4fecc0643fe726bdc2 Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 2 Apr 2026 01:53:15 -0700 Subject: [PATCH 1/3] feat(core): build and persist reverse callee index in graph v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/src/commands/refs.ts | 2 +- packages/core/src/indexer/index.ts | 9 +- packages/core/src/map/__tests__/graph.test.ts | 66 +++- .../src/map/__tests__/reverse-index.test.ts | 315 ++++++++++++++++++ packages/core/src/map/graph.ts | 77 ++++- packages/core/src/map/index.ts | 3 +- packages/core/src/map/reverse-index.ts | 253 ++++++++++++++ packages/core/src/map/types.ts | 14 + .../src/adapters/built-in/refs-adapter.ts | 3 +- .../src/watcher/incremental-indexer.ts | 15 +- 10 files changed, 714 insertions(+), 43 deletions(-) create mode 100644 packages/core/src/map/__tests__/reverse-index.test.ts create mode 100644 packages/core/src/map/reverse-index.ts diff --git a/packages/cli/src/commands/refs.ts b/packages/cli/src/commands/refs.ts index 651d4be..b0048b4 100644 --- a/packages/cli/src/commands/refs.ts +++ b/packages/cli/src/commands/refs.ts @@ -70,7 +70,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) || ''; diff --git a/packages/core/src/indexer/index.ts b/packages/core/src/indexer/index.ts index e2cb1fa..72be3a1 100644 --- a/packages/core/src/indexer/index.ts +++ b/packages/core/src/indexer/index.ts @@ -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'; @@ -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'); diff --git a/packages/core/src/map/__tests__/graph.test.ts b/packages/core/src/map/__tests__/graph.test.ts index c5167bd..b7f03da 100644 --- a/packages/core/src/map/__tests__/graph.test.ts +++ b/packages/core/src/map/__tests__/graph.test.ts @@ -343,34 +343,69 @@ describe('shortestPath', () => { // ============================================================================ describe('serializeGraph / deserializeGraph', () => { - it('should round-trip correctly', () => { + it('should round-trip graph correctly', () => { const graph = new Map(); 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(); 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(); + 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(); }); @@ -388,9 +423,9 @@ describe('serializeGraph / deserializeGraph', () => { it('should handle empty graph', () => { const graph = new Map(); 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); }); }); @@ -411,8 +446,9 @@ 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(); + expect(result.reverseIndex).toBeNull(); // fallback doesn't build reverse index }); it('should call fallback when graphPath file does not exist', async () => { @@ -427,8 +463,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(); }); }); diff --git a/packages/core/src/map/__tests__/reverse-index.test.ts b/packages/core/src/map/__tests__/reverse-index.test.ts new file mode 100644 index 0000000..767e01c --- /dev/null +++ b/packages/core/src/map/__tests__/reverse-index.test.ts @@ -0,0 +1,315 @@ +/** + * Reverse Callee Index Tests + * + * All pure functions — no I/O, no mocks needed. + */ + +import { describe, expect, it } from 'vitest'; +import type { SearchResult } from '../../vector/types'; +import { + buildNameIndex, + buildReverseCalleeIndex, + lookupCallers, + lookupClassCallers, + updateReverseIndexIncremental, +} from '../reverse-index'; +import type { CallerEntry } from '../types'; + +// Helper to create mock documents +function mockDoc( + filePath: string, + name: string, + type: string, + callees: Array<{ name: string; line: number; file?: string }> +): SearchResult { + return { + id: `${filePath}:${name}:1`, + score: 0, + metadata: { + path: filePath, + name, + type, + callees, + }, + }; +} + +// ============================================================================ +// buildReverseCalleeIndex +// ============================================================================ + +describe('buildReverseCalleeIndex', () => { + it('should map compound keys for resolved callees', () => { + const docs = [ + mockDoc('src/a.ts', 'funcA', 'function', [ + { name: 'validateArgs', line: 5, file: 'src/validate.ts' }, + { name: 'console.log', line: 10 }, + ]), + mockDoc('src/b.ts', 'funcB', 'function', [ + { name: 'validateArgs', line: 3, file: 'src/validate.ts' }, + ]), + ]; + + const index = buildReverseCalleeIndex(docs); + + // Compound key for resolved file + expect(index.get('src/validate.ts:validateArgs')).toHaveLength(2); + // Bare name key for unresolved + expect(index.get('console.log')).toHaveLength(1); + expect(index.get('console.log')![0].name).toBe('funcA'); + }); + + it('should store caller metadata correctly', () => { + const docs = [ + mockDoc('src/adapter.ts', 'SearchAdapter.execute', 'method', [ + { name: 'validateArgs', line: 124, file: 'src/validate.ts' }, + ]), + ]; + + const index = buildReverseCalleeIndex(docs); + const callers = index.get('src/validate.ts:validateArgs')!; + + expect(callers[0]).toEqual({ + name: 'SearchAdapter.execute', + file: 'src/adapter.ts', + line: 124, + type: 'method', + }); + }); + + it('should handle docs with no callees', () => { + const docs = [mockDoc('src/a.ts', 'MyInterface', 'interface', [])]; + const index = buildReverseCalleeIndex(docs); + expect(index.size).toBe(0); + }); + + it('should handle docs with undefined callees', () => { + const docs: SearchResult[] = [ + { id: 'test', score: 0, metadata: { path: 'a.ts', name: 'func' } }, + ]; + const index = buildReverseCalleeIndex(docs); + expect(index.size).toBe(0); + }); + + it('should handle empty docs array', () => { + const index = buildReverseCalleeIndex([]); + expect(index.size).toBe(0); + }); +}); + +// ============================================================================ +// buildNameIndex +// ============================================================================ + +describe('buildNameIndex', () => { + it('should map last segment to compound keys', () => { + const reverseIndex = new Map([ + ['src/validate.ts:validateArgs', []], + ['src/search.ts:this.searchService.search', []], + ['new CompactFormatter', []], + ]); + + const nameIndex = buildNameIndex(reverseIndex); + + expect(nameIndex.get('validateArgs')).toContain('src/validate.ts:validateArgs'); + expect(nameIndex.get('search')).toContain('src/search.ts:this.searchService.search'); + expect(nameIndex.get('CompactFormatter')).toContain('new CompactFormatter'); + }); + + it('should index qualified names under class prefix', () => { + const reverseIndex = new Map([ + ['src/a.ts:SearchAdapter.execute', []], + ['src/a.ts:SearchAdapter.initialize', []], + ]); + + const nameIndex = buildNameIndex(reverseIndex); + + // Both should be indexed under "SearchAdapter" + const keys = nameIndex.get('SearchAdapter') ?? []; + expect(keys).toContain('src/a.ts:SearchAdapter.execute'); + expect(keys).toContain('src/a.ts:SearchAdapter.initialize'); + }); + + it('should handle bare name keys', () => { + const reverseIndex = new Map([['console.log', []]]); + + const nameIndex = buildNameIndex(reverseIndex); + + expect(nameIndex.get('log')).toContain('console.log'); + expect(nameIndex.get('console.log')).toContain('console.log'); + }); +}); + +// ============================================================================ +// lookupCallers +// ============================================================================ + +describe('lookupCallers', () => { + const reverseIndex = new Map([ + [ + 'src/validate.ts:validateArgs', + [ + { name: 'SearchAdapter.execute', file: 'src/search-adapter.ts', line: 124, type: 'method' }, + { name: 'RefsAdapter.execute', file: 'src/refs-adapter.ts', line: 168, type: 'method' }, + ], + ], + [ + 'this.searchService.search', + [{ name: 'SearchAdapter.execute', file: 'src/search-adapter.ts', line: 141, type: 'method' }], + ], + ]); + + const nameIndex = buildNameIndex(reverseIndex); + + it('should find callers by compound key', () => { + const callers = lookupCallers(reverseIndex, nameIndex, 'validateArgs', 'src/validate.ts'); + expect(callers).toHaveLength(2); + }); + + it('should find callers by bare name via nameIndex', () => { + const callers = lookupCallers(reverseIndex, nameIndex, 'validateArgs', 'unknown-file.ts'); + expect(callers).toHaveLength(2); + }); + + it('should find callers by last segment', () => { + const callers = lookupCallers(reverseIndex, nameIndex, 'search', 'unknown.ts'); + expect(callers).toHaveLength(1); + expect(callers[0].name).toBe('SearchAdapter.execute'); + }); + + it('should deduplicate by file+name', () => { + const callers = lookupCallers(reverseIndex, nameIndex, 'validateArgs', 'src/validate.ts'); + const keys = callers.map((c) => `${c.file}:${c.name}`); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('should respect limit', () => { + const callers = lookupCallers(reverseIndex, nameIndex, 'validateArgs', 'src/validate.ts', 1); + expect(callers).toHaveLength(1); + }); + + it('should return empty for unknown name', () => { + const callers = lookupCallers(reverseIndex, nameIndex, 'nonexistent', 'x.ts'); + expect(callers).toHaveLength(0); + }); +}); + +// ============================================================================ +// lookupClassCallers +// ============================================================================ + +describe('lookupClassCallers', () => { + const reverseIndex = new Map([ + [ + 'new CompactFormatter', + [{ name: 'SearchAdapter.execute', file: 'search.ts', line: 154, type: 'method' }], + ], + [ + 'search.ts:CompactFormatter.formatResults', + [{ name: 'SearchAdapter.execute', file: 'search.ts', line: 161, type: 'method' }], + ], + [ + 'other.ts:CompactFormatter.estimateTokens', + [{ name: 'OtherService.run', file: 'other.ts', line: 20, type: 'method' }], + ], + ]); + + const nameIndex = buildNameIndex(reverseIndex); + + it('should aggregate constructor and method callers', () => { + const callers = lookupClassCallers(reverseIndex, nameIndex, 'CompactFormatter', 'fmt.ts'); + const names = callers.map((c) => c.name); + expect(names).toContain('SearchAdapter.execute'); + expect(names).toContain('OtherService.run'); + }); + + it('should deduplicate across constructor and method callers', () => { + const callers = lookupClassCallers(reverseIndex, nameIndex, 'CompactFormatter', 'fmt.ts'); + // SearchAdapter.execute appears in both constructor and method — should be deduped + const keys = callers.map((c) => `${c.file}:${c.name}`); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('should return empty for unknown class', () => { + const callers = lookupClassCallers(reverseIndex, nameIndex, 'NonExistent', 'x.ts'); + expect(callers).toHaveLength(0); + }); +}); + +// ============================================================================ +// updateReverseIndexIncremental +// ============================================================================ + +describe('updateReverseIndexIncremental', () => { + it('should remove entries from changed files', () => { + const existing = new Map([ + [ + 'src/validate.ts:funcA', + [ + { name: 'caller1', file: 'old.ts', line: 5, type: 'function' }, + { name: 'caller2', file: 'other.ts', line: 10, type: 'function' }, + ], + ], + ]); + + const changedDocs = [mockDoc('old.ts', 'caller1New', 'function', [{ name: 'funcB', line: 5 }])]; + + const updated = updateReverseIndexIncremental(existing, changedDocs, []); + + // caller1 from old.ts removed, caller2 stays + expect(updated.get('src/validate.ts:funcA')).toHaveLength(1); + expect(updated.get('src/validate.ts:funcA')![0].name).toBe('caller2'); + // new entry for funcB + expect(updated.get('funcB')).toHaveLength(1); + }); + + it('should remove entries for deleted files', () => { + const existing = new Map([ + [ + 'src/validate.ts:funcA', + [ + { name: 'caller1', file: 'deleted.ts', line: 5, type: 'function' }, + { name: 'caller2', file: 'kept.ts', line: 10, type: 'function' }, + ], + ], + ]); + + const updated = updateReverseIndexIncremental(existing, [], ['deleted.ts']); + + expect(updated.get('src/validate.ts:funcA')).toHaveLength(1); + expect(updated.get('src/validate.ts:funcA')![0].file).toBe('kept.ts'); + }); + + it('should remove compound keys whose file is deleted', () => { + const existing = new Map([ + ['deleted.ts:funcA', [{ name: 'caller1', file: 'kept.ts', line: 5, type: 'function' }]], + ]); + + const updated = updateReverseIndexIncremental(existing, [], ['deleted.ts']); + + expect(updated.has('deleted.ts:funcA')).toBe(false); + }); + + it('should not mutate the original map', () => { + const original = new Map([ + ['a.ts:funcA', [{ name: 'caller1', file: 'old.ts', line: 5, type: 'function' }]], + ]); + const originalArray = original.get('a.ts:funcA')!; + const originalLength = originalArray.length; + + updateReverseIndexIncremental(original, [], ['old.ts']); + + expect(original.get('a.ts:funcA')).toHaveLength(originalLength); + expect(original.get('a.ts:funcA')).toBe(originalArray); + }); + + it('should clean up empty keys', () => { + const existing = new Map([ + ['a.ts:funcA', [{ name: 'caller1', file: 'old.ts', line: 5, type: 'function' }]], + ]); + + const updated = updateReverseIndexIncremental(existing, [], ['old.ts']); + + expect(updated.has('a.ts:funcA')).toBe(false); + }); +}); diff --git a/packages/core/src/map/graph.ts b/packages/core/src/map/graph.ts index 899fce2..dabfb03 100644 --- a/packages/core/src/map/graph.ts +++ b/packages/core/src/map/graph.ts @@ -13,6 +13,7 @@ import * as fs from 'node:fs/promises'; import type { SearchResult } from '../vector/types'; +import type { CallerEntry } from './types'; // ============================================================================ // Types @@ -24,11 +25,15 @@ export interface WeightedEdge { } export interface CachedGraph { - version: 1; + version: 1 | 2; generatedAt: string; nodeCount: number; edgeCount: number; graph: Record; + /** v2: reverse callee index — compound key (file:name) → callers */ + reverseIndex?: Record; + /** v2: total entries across all reverse index keys */ + reverseIndexEntryCount?: number; } // ============================================================================ @@ -272,43 +277,78 @@ export function shortestPath( // Serialization // ============================================================================ -const GRAPH_VERSION = 1; - /** - * Serialize a dependency graph to JSON string. + * Serialize a dependency graph (and optional reverse index) to JSON string. + * Always writes v2 format. v1 readers will reject on version check (expected). */ -export function serializeGraph(graph: Map): string { +export function serializeGraph( + graph: Map, + reverseIndex?: Map, + generatedAt?: string +): string { let edgeCount = 0; - const obj: Record = {}; + const graphObj: Record = {}; for (const [key, edges] of graph) { - obj[key] = edges; + graphObj[key] = edges; edgeCount += edges.length; } + + let reverseObj: Record | undefined; + let reverseEntryCount: number | undefined; + if (reverseIndex) { + reverseObj = {}; + reverseEntryCount = 0; + for (const [key, entries] of reverseIndex) { + reverseObj[key] = entries; + reverseEntryCount += entries.length; + } + } + const cached: CachedGraph = { - version: GRAPH_VERSION, - generatedAt: new Date().toISOString(), + version: 2, + generatedAt: generatedAt ?? new Date().toISOString(), nodeCount: graph.size, edgeCount, - graph: obj, + graph: graphObj, + reverseIndex: reverseObj, + reverseIndexEntryCount: reverseEntryCount, }; return JSON.stringify(cached); } +/** + * Deserialized graph result — graph is always present, reverseIndex is null for v1. + */ +export interface DeserializedGraph { + graph: Map; + reverseIndex: Map | null; +} + /** * Deserialize a JSON string to a dependency graph. - * Returns null if JSON is invalid or version doesn't match. + * Handles both v1 (graph only) and v2 (graph + reverse index). + * Returns null if JSON is invalid or version is unsupported. */ -export function deserializeGraph(json: string): Map | null { +export function deserializeGraph(json: string): DeserializedGraph | null { try { const data = JSON.parse(json) as CachedGraph; - if (data.version !== GRAPH_VERSION) return null; + if (data.version !== 1 && data.version !== 2) return null; if (!data.graph || typeof data.graph !== 'object') return null; const graph = new Map(); for (const [key, edges] of Object.entries(data.graph)) { graph.set(key, edges as WeightedEdge[]); } - return graph; + + let reverseIndex: Map | null = null; + if (data.reverseIndex) { + reverseIndex = new Map(); + for (const [key, entries] of Object.entries(data.reverseIndex)) { + reverseIndex.set(key, entries); + } + } + + return { graph, reverseIndex }; } catch { return null; } @@ -320,23 +360,24 @@ export function deserializeGraph(json: string): Map | nu /** * Load dependency graph from cache, or build from docs as fallback. + * Returns both graph and reverse index (null if v1 or rebuilt from fallback). */ export async function loadOrBuildGraph( graphPath: string | undefined, fallbackDocs: () => Promise -): Promise> { +): Promise { if (graphPath) { try { const json = await fs.readFile(graphPath, 'utf-8'); - const graph = deserializeGraph(json); - if (graph) return graph; + const result = deserializeGraph(json); + if (result) return result; } catch { // File missing or unreadable — fall through to build } } const docs = await fallbackDocs(); - return buildDependencyGraph(docs); + return { graph: buildDependencyGraph(docs), reverseIndex: null }; } // ============================================================================ diff --git a/packages/core/src/map/index.ts b/packages/core/src/map/index.ts index 2501ec6..95a7001 100644 --- a/packages/core/src/map/index.ts +++ b/packages/core/src/map/index.ts @@ -22,6 +22,7 @@ import type { export { GitExtractor, LocalGitExtractor } from './git-extractor'; export * from './git-types'; export * from './graph'; +export * from './reverse-index'; export * from './types'; /** Default options for map generation */ @@ -123,7 +124,7 @@ export async function generateCodebaseMap( // Load cached dependency graph or build from docs as fallback const t7 = Date.now(); - const graph = await loadOrBuildGraph(context.graphPath, async () => allDocs); + const { graph } = await loadOrBuildGraph(context.graphPath, async () => allDocs); const hotPaths = opts.includeHotPaths ? computeHotPaths(allDocs, graph, opts.maxHotPaths) : []; const rawComponents = connectedComponents(graph); const components = rawComponents diff --git a/packages/core/src/map/reverse-index.ts b/packages/core/src/map/reverse-index.ts new file mode 100644 index 0000000..769d485 --- /dev/null +++ b/packages/core/src/map/reverse-index.ts @@ -0,0 +1,253 @@ +/** + * Reverse Callee Index + * + * Maps callee names to the components that call them. + * Enables efficient "find all callers" queries — replaces the broken + * semantic-search-then-scan approach in dev_refs. + * + * Pure functions — no I/O, no side effects, trivially testable. + */ + +import type { CalleeInfo } from '../scanner/types.js'; +import type { SearchResult } from '../vector/types.js'; +import type { CallerEntry } from './types.js'; + +// ============================================================================ +// Build +// ============================================================================ + +/** + * Build reverse callee index from indexed documents. + * Key format: "file:name" (compound) when callee file is resolved, + * bare "name" when not (tree-sitter languages without file resolution). + */ +export function buildReverseCalleeIndex(docs: SearchResult[]): Map { + const index = new Map(); + + for (const doc of docs) { + const callees = doc.metadata.callees as CalleeInfo[] | undefined; + if (!callees || callees.length === 0) continue; + + const callerName = typeof doc.metadata.name === 'string' ? doc.metadata.name : 'unknown'; + const callerFile = typeof doc.metadata.path === 'string' ? doc.metadata.path : ''; + const callerType = typeof doc.metadata.type === 'string' ? doc.metadata.type : 'unknown'; + + for (const callee of callees) { + const key = callee.file ? `${callee.file}:${callee.name}` : callee.name; + + const entry: CallerEntry = { + name: callerName, + file: callerFile, + line: callee.line, + type: callerType, + }; + + const existing = index.get(key); + if (existing) { + existing.push(entry); + } else { + index.set(key, [entry]); + } + } + } + + return index; +} + +// ============================================================================ +// Name Index (secondary index for bare-name lookups) +// ============================================================================ + +/** + * Build secondary name index for bare-name lookups. + * Maps name segments to the full compound keys they appear in. + * + * "src/validate.ts:validateArgs" → indexed under "validateArgs" + * "src/search.ts:this.searchService.search" → indexed under "search", "this.searchService.search" + * "new CompactFormatter" → indexed under "CompactFormatter" + * "src/a.ts:ClassName.method" → indexed under "method", "ClassName.method", "ClassName" + * + * Built in memory at load time. Not persisted. + */ +export function buildNameIndex(reverseIndex: Map): Map { + const nameIndex = new Map(); + + for (const key of reverseIndex.keys()) { + // Extract name from compound key "file:name" or bare "name" + const colonIdx = key.lastIndexOf(':'); + const name = colonIdx >= 0 ? key.slice(colonIdx + 1) : key; + // Handle "new Foo" → "Foo" + const cleaned = name.startsWith('new ') ? name.slice(4) : name; + // Handle qualified: "this.service.search" → "search" + const dotIdx = cleaned.lastIndexOf('.'); + const lastSegment = dotIdx >= 0 ? cleaned.slice(dotIdx + 1) : cleaned; + + const segments = new Set([cleaned, lastSegment]); + + // For "ClassName.method", also index under "ClassName" for class aggregation + if (cleaned.includes('.')) { + const classPrefix = cleaned.split('.')[0]; + segments.add(classPrefix); + } + + for (const segment of segments) { + const existing = nameIndex.get(segment); + if (existing) { + existing.push(key); + } else { + nameIndex.set(segment, [key]); + } + } + } + + return nameIndex; +} + +// ============================================================================ +// Lookup +// ============================================================================ + +/** + * Deduplicate caller entries by file+name, cap at limit. + */ +function deduplicateCallers(candidates: CallerEntry[], limit: number): CallerEntry[] { + const seen = new Set(); + const results: CallerEntry[] = []; + for (const entry of candidates) { + const key = `${entry.file}:${entry.name}`; + if (seen.has(key)) continue; + seen.add(key); + results.push(entry); + if (results.length >= limit) break; + } + return results; +} + +/** + * Look up callers of a target from the reverse callee index. + * + * 1. Try compound key: "targetFile:targetName" → O(1) + * 2. Fall back to nameIndex for bare-name resolution → O(1) + * 3. Deduplicate by caller file+name, cap at limit + */ +export function lookupCallers( + reverseIndex: Map, + nameIndex: Map, + targetName: string, + targetFile: string, + limit = 50 +): CallerEntry[] { + const candidates: CallerEntry[] = []; + + // 1. Compound key — exact match, O(1) + const compoundKey = `${targetFile}:${targetName}`; + const exact = reverseIndex.get(compoundKey); + if (exact) candidates.push(...exact); + + // 2. Bare name — use name index for O(1) resolution + const fullKeys = nameIndex.get(targetName) ?? []; + for (const key of fullKeys) { + if (key === compoundKey) continue; // already collected + const entries = reverseIndex.get(key); + if (entries) candidates.push(...entries); + } + + return deduplicateCallers(candidates, limit); +} + +/** + * Look up callers of a class, aggregating across constructor and methods. + * The nameIndex indexes "ClassName.method" keys under "ClassName", + * so a single O(1) lookup returns all constructor + method compound keys. + */ +export function lookupClassCallers( + reverseIndex: Map, + nameIndex: Map, + className: string, + _classFile: string, + limit = 50 +): CallerEntry[] { + const candidates: CallerEntry[] = []; + + // nameIndex indexes "ClassName.method" under "ClassName" prefix, + // and "new ClassName" under "ClassName". Single O(1) lookup. + const fullKeys = nameIndex.get(className) ?? []; + for (const key of fullKeys) { + const entries = reverseIndex.get(key); + if (entries) candidates.push(...entries); + } + + return deduplicateCallers(candidates, limit); +} + +// ============================================================================ +// Incremental Update +// ============================================================================ + +/** + * Incrementally update the reverse callee index. + * + * 1. Deep copy existing (don't mutate original) + * 2. Remove entries where caller file is in changedFiles or deletedFiles + * 3. Remove compound keys whose file is in deletedFiles + * 4. Rebuild entries from changedDocs + * + * Returns a new map (does not mutate existing). + */ +export function updateReverseIndexIncremental( + existing: Map, + changedDocs: SearchResult[], + deletedFiles: string[] +): Map { + // Deep copy — shallow Map copy shares CallerEntry[] references + const updated = new Map(); + for (const [key, entries] of existing) { + updated.set(key, [...entries]); + } + + const removedFiles = new Set(deletedFiles); + + // Collect files from changed docs + const changedFiles = new Set(); + for (const doc of changedDocs) { + const file = doc.metadata.path as string; + if (file) changedFiles.add(file); + } + + // Remove stale entries + for (const [key, entries] of updated) { + // Remove compound keys whose file is deleted + const colonIdx = key.lastIndexOf(':'); + if (colonIdx >= 0) { + const keyFile = key.slice(0, colonIdx); + if (removedFiles.has(keyFile)) { + updated.delete(key); + continue; + } + } + + // Filter out caller entries from changed/deleted files + const filtered = entries.filter( + (entry) => !changedFiles.has(entry.file) && !removedFiles.has(entry.file) + ); + + if (filtered.length === 0) { + updated.delete(key); + } else { + updated.set(key, filtered); + } + } + + // Rebuild entries from changed docs + const newEntries = buildReverseCalleeIndex(changedDocs); + for (const [key, entries] of newEntries) { + const current = updated.get(key); + if (current) { + current.push(...entries); + } else { + updated.set(key, [...entries]); + } + } + + return updated; +} diff --git a/packages/core/src/map/types.ts b/packages/core/src/map/types.ts index f038faf..7bdb591 100644 --- a/packages/core/src/map/types.ts +++ b/packages/core/src/map/types.ts @@ -91,6 +91,20 @@ export interface HotPath { primaryComponent?: string; } +/** + * Entry in the reverse callee index — represents a caller of a function/method. + */ +export interface CallerEntry { + /** Caller component name (e.g., "SearchAdapter.execute") */ + name: string; + /** Caller file path */ + file: string; + /** Call site line in caller */ + line: number; + /** Caller component type (function, method, class) */ + type: string; +} + /** * Result of codebase map generation */ diff --git a/packages/mcp-server/src/adapters/built-in/refs-adapter.ts b/packages/mcp-server/src/adapters/built-in/refs-adapter.ts index f02fa9b..dbbffc9 100644 --- a/packages/mcp-server/src/adapters/built-in/refs-adapter.ts +++ b/packages/mcp-server/src/adapters/built-in/refs-adapter.ts @@ -109,7 +109,7 @@ export class RefsAdapter extends ToolAdapter { return this.cachedGraph; } - this.cachedGraph = await loadOrBuildGraph(this.graphPath, async () => { + const result = await loadOrBuildGraph(this.graphPath, async () => { const DOC_LIMIT = 50_000; const allDocs = await this.indexer!.getAll({ limit: DOC_LIMIT }); if (allDocs.length >= DOC_LIMIT) { @@ -119,6 +119,7 @@ export class RefsAdapter extends ToolAdapter { } return allDocs; }); + this.cachedGraph = result.graph; this.cachedGraphTime = Date.now(); return this.cachedGraph; } diff --git a/packages/mcp-server/src/watcher/incremental-indexer.ts b/packages/mcp-server/src/watcher/incremental-indexer.ts index a66e84d..0c9afee 100644 --- a/packages/mcp-server/src/watcher/incremental-indexer.ts +++ b/packages/mcp-server/src/watcher/incremental-indexer.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { + buildReverseCalleeIndex, deserializeGraph, type EmbeddingDocument, prepareDocumentsForEmbedding, @@ -16,6 +17,7 @@ import { scanRepository, serializeGraph, updateGraphIncremental, + updateReverseIndexIncremental, } from '@prosdevlab/dev-agent-core'; // ── Types ──────────────────────────────────────────────────────────────── @@ -130,20 +132,23 @@ export function createIncrementalIndexer(config: IncrementalIndexerConfig): { `[MCP] Incremental update: ${upserts.length} upserted, ${deleteIds.length} deleted` ); - // 5. Update cached dependency graph + // 5. Update cached dependency graph + reverse index (atomic write) if (graphPath) { try { const json = await fs.readFile(graphPath, 'utf-8'); - const existing = deserializeGraph(json); - if (existing) { + const result = deserializeGraph(json); + if (result) { const deletedFiles = deleted.map((f) => path.relative(repositoryPath, f)); const changedDocs = upserts.map((d) => ({ id: d.id, score: 0, metadata: d.metadata, })); - const updated = updateGraphIncremental(existing, changedDocs, deletedFiles); - await fs.writeFile(graphPath, serializeGraph(updated), 'utf-8'); + const updatedGraph = updateGraphIncremental(result.graph, changedDocs, deletedFiles); + const updatedReverse = result.reverseIndex + ? updateReverseIndexIncremental(result.reverseIndex, changedDocs, deletedFiles) + : buildReverseCalleeIndex(changedDocs); + await fs.writeFile(graphPath, serializeGraph(updatedGraph, updatedReverse), 'utf-8'); } } catch { // Graph update failed — next full index will fix it From 6b862829d1399d0093bc8c0bb4f4073e4b334026 Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 2 Apr 2026 02:04:33 -0700 Subject: [PATCH 2/3] 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) --- packages/cli/src/commands/refs.ts | 43 ++++----- packages/core/src/map/__tests__/graph.test.ts | 4 +- packages/core/src/map/graph.ts | 6 +- .../adapters/__tests__/refs-adapter.test.ts | 20 +++- .../src/adapters/built-in/refs-adapter.ts | 92 ++++++++++--------- 5 files changed, 94 insertions(+), 71 deletions(-) diff --git a/packages/cli/src/commands/refs.ts b/packages/cli/src/commands/refs.ts index b0048b4..cb24cdd 100644 --- a/packages/cli/src/commands/refs.ts +++ b/packages/cli/src/commands/refs.ts @@ -1,9 +1,12 @@ import * as path from 'node:path'; import { + buildNameIndex, ensureStorageDirectory, getStorageFilePaths, getStoragePath, loadOrBuildGraph, + lookupCallers, + lookupClassCallers, RepositoryIndexer, type SearchResult, shortestPath, @@ -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 }); } } } diff --git a/packages/core/src/map/__tests__/graph.test.ts b/packages/core/src/map/__tests__/graph.test.ts index b7f03da..8ab13eb 100644 --- a/packages/core/src/map/__tests__/graph.test.ts +++ b/packages/core/src/map/__tests__/graph.test.ts @@ -448,7 +448,9 @@ describe('loadOrBuildGraph', () => { const result = await loadOrBuildGraph(undefined, async () => fallbackDocs); expect(result.graph.get('src/a.ts')).toBeDefined(); - expect(result.reverseIndex).toBeNull(); // fallback doesn't build reverse index + // 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 () => { diff --git a/packages/core/src/map/graph.ts b/packages/core/src/map/graph.ts index dabfb03..fbb92a1 100644 --- a/packages/core/src/map/graph.ts +++ b/packages/core/src/map/graph.ts @@ -13,6 +13,7 @@ import * as fs from 'node:fs/promises'; import type { SearchResult } from '../vector/types'; +import { buildReverseCalleeIndex } from './reverse-index'; import type { CallerEntry } from './types'; // ============================================================================ @@ -377,7 +378,10 @@ export async function loadOrBuildGraph( } const docs = await fallbackDocs(); - return { graph: buildDependencyGraph(docs), reverseIndex: null }; + return { + graph: buildDependencyGraph(docs), + reverseIndex: docs.length > 0 ? buildReverseCalleeIndex(docs) : null, + }; } // ============================================================================ diff --git a/packages/mcp-server/src/adapters/__tests__/refs-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/refs-adapter.test.ts index 99535a3..3da0e05 100644 --- a/packages/mcp-server/src/adapters/__tests__/refs-adapter.test.ts +++ b/packages/mcp-server/src/adapters/__tests__/refs-adapter.test.ts @@ -74,9 +74,17 @@ describe('RefsAdapter', () => { search: vi.fn().mockResolvedValue(mockSearchResults), } as unknown as SearchService; - // Create adapter + // Create mock indexer for reverse index building + const mockIndexer = { + getAll: vi.fn().mockResolvedValue(mockSearchResults), + initialize: vi.fn(), + close: vi.fn(), + }; + + // Create adapter with indexer so reverse index can be built adapter = new RefsAdapter({ searchService: mockSearchService, + indexer: mockIndexer as any, defaultLimit: 20, }); @@ -349,8 +357,14 @@ describe('RefsAdapter', () => { }); it('should return error when indexer is not available', async () => { - // The base adapter (no indexer) should fail for dependsOn - const result = await adapter.execute( + // Create an adapter without indexer — dependsOn requires it + const adapterNoIndexer = new RefsAdapter({ + searchService: mockSearchService, + defaultLimit: 20, + }); + await adapterNoIndexer.initialize(context); + + const result = await adapterNoIndexer.execute( { name: 'createPlan', dependsOn: 'src/api.ts' }, execContext ); diff --git a/packages/mcp-server/src/adapters/built-in/refs-adapter.ts b/packages/mcp-server/src/adapters/built-in/refs-adapter.ts index dbbffc9..8a13806 100644 --- a/packages/mcp-server/src/adapters/built-in/refs-adapter.ts +++ b/packages/mcp-server/src/adapters/built-in/refs-adapter.ts @@ -5,11 +5,18 @@ import type { CalleeInfo, + CallerEntry, RepositoryIndexer, SearchResult, SearchService, } from '@prosdevlab/dev-agent-core'; -import { loadOrBuildGraph, shortestPath } from '@prosdevlab/dev-agent-core'; +import { + buildNameIndex, + loadOrBuildGraph, + lookupCallers, + lookupClassCallers, + shortestPath, +} from '@prosdevlab/dev-agent-core'; import { estimateTokensForText, startTimer } from '../../formatters/utils'; import { RefsArgsSchema } from '../../schemas/index.js'; import { ToolAdapter } from '../tool-adapter'; @@ -78,6 +85,8 @@ export class RefsAdapter extends ToolAdapter { private indexer?: RepositoryIndexer; private graphPath?: string; private cachedGraph?: Map; + private cachedReverseIndex: Map | null = null; + private cachedNameIndex: Map | null = null; private cachedGraphTime = 0; constructor(config: RefsAdapterConfig) { @@ -110,8 +119,9 @@ export class RefsAdapter extends ToolAdapter { } const result = await loadOrBuildGraph(this.graphPath, async () => { + if (!this.indexer) return []; const DOC_LIMIT = 50_000; - const allDocs = await this.indexer!.getAll({ limit: DOC_LIMIT }); + const allDocs = await this.indexer.getAll({ limit: DOC_LIMIT }); if (allDocs.length >= DOC_LIMIT) { console.error( `[dev-agent] Warning: dependency graph hit ${DOC_LIMIT} doc limit. Results may be incomplete.` @@ -120,6 +130,8 @@ export class RefsAdapter extends ToolAdapter { return allDocs; }); this.cachedGraph = result.graph; + this.cachedReverseIndex = result.reverseIndex; + this.cachedNameIndex = result.reverseIndex ? buildNameIndex(result.reverseIndex) : null; this.cachedGraphTime = Date.now(); return this.cachedGraph; } @@ -251,7 +263,9 @@ export class RefsAdapter extends ToolAdapter { // Get callers if requested if (direction === 'callers' || direction === 'both') { - result.callers = await this.getCallers(target, limit); + // Ensure graph (and reverse index) is loaded before looking up callers + await this.getDependencyGraph(); + result.callers = this.getCallersFromIndex(target, limit); } const content = this.formatOutput(result, direction); @@ -322,47 +336,39 @@ export class RefsAdapter extends ToolAdapter { } /** - * Find callers by searching all indexed components for callees that reference the target + * Find callers using the reverse callee index. + * Falls back to empty results if no reverse index is available (v1 graph). */ - private async getCallers(target: SearchResult, limit: number): Promise { - const targetName = target.metadata.name; - if (!targetName) return []; - - // Search for components that might call this target - // We search broadly and then filter by callees - const candidates = await this.searchService.search(targetName, { limit: 100 }); - - const callers: RefResult[] = []; - - for (const candidate of candidates) { - // Skip the target itself - if (candidate.id === target.id) continue; - - const callees = candidate.metadata.callees as CalleeInfo[] | undefined; - if (!callees) continue; - - // Check if any callee matches our target - const callsTarget = callees.some( - (c) => - c.name === targetName || - c.name.endsWith(`.${targetName}`) || - targetName.endsWith(`.${c.name}`) - ); - - if (callsTarget) { - callers.push({ - name: candidate.metadata.name || 'unknown', - file: candidate.metadata.path, - line: candidate.metadata.startLine || 0, - type: candidate.metadata.type as string, - snippet: candidate.metadata.signature as string | undefined, - }); - - if (callers.length >= limit) break; - } - } - - return callers; + private getCallersFromIndex(target: SearchResult, limit: number): RefResult[] { + if (!this.cachedReverseIndex || !this.cachedNameIndex) return []; + + const targetName = (target.metadata.name as string) || ''; + const targetFile = (target.metadata.path as string) || ''; + const targetType = target.metadata.type as string; + + const callers = + targetType === 'class' + ? lookupClassCallers( + this.cachedReverseIndex, + this.cachedNameIndex, + targetName, + targetFile, + limit + ) + : lookupCallers( + this.cachedReverseIndex, + this.cachedNameIndex, + targetName, + targetFile, + limit + ); + + return callers.map((c) => ({ + name: c.name, + file: c.file, + line: c.line, + type: c.type, + })); } /** From f21310a1d69d55ef29364fccaae4cc081bfd2e28 Mon Sep 17 00:00:00 2001 From: prosdev Date: Thu, 2 Apr 2026 02:11:55 -0700 Subject: [PATCH 3/3] chore: add changeset and release notes for reverse callee index Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/reverse-callee-index.md | 5 +++++ website/content/latest-version.ts | 10 +++++----- website/content/updates/index.mdx | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 .changeset/reverse-callee-index.md diff --git a/.changeset/reverse-callee-index.md b/.changeset/reverse-callee-index.md new file mode 100644 index 0000000..01fe8b2 --- /dev/null +++ b/.changeset/reverse-callee-index.md @@ -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. diff --git a/website/content/latest-version.ts b/website/content/latest-version.ts index c47ba51..5f8f2cc 100644 --- a/website/content/latest-version.ts +++ b/website/content/latest-version.ts @@ -4,10 +4,10 @@ */ export const latestVersion = { - version: '0.12.1', - title: 'Cleaner Search Output + refs Fix', - date: 'April 1, 2026', + version: '0.12.2', + title: 'Reverse Callee Index for dev_refs', + date: 'April 2, 2026', summary: - 'MCP search results drop misleading scores, add result preamble, and fix dev_refs silently returning no results.', - link: '/updates#v0121--cleaner-search-output--refs-fix', + 'dev_refs callers finally work — reverse callee index with 4,000+ entries, compound keys, class aggregation, and O(1) lookups.', + link: '/updates#v0122--reverse-callee-index-for-dev_refs', } as const; diff --git a/website/content/updates/index.mdx b/website/content/updates/index.mdx index 36af1f7..9ecfdcf 100644 --- a/website/content/updates/index.mdx +++ b/website/content/updates/index.mdx @@ -9,6 +9,22 @@ What's new in dev-agent. We ship improvements regularly to help AI assistants un --- +## v0.12.2 — Reverse Callee Index for dev_refs + +*April 2, 2026* + +**`dev_refs` callers finally work.** + +- **Reverse callee index:** maps callee names to their callers, built at index time from scan data. 4,000+ caller entries on a typical codebase +- **Compound keys:** `file:name` for TypeScript (O(1) exact match), bare names for Go/Rust/Python with secondary name index for O(1) resolution +- **Single artifact:** persisted inside `dependency-graph.json` v2 — atomic write, no drift between graph and reverse index +- **Class aggregation:** `dev refs "SearchAdapter"` aggregates callers across constructor + all methods +- **Backward compatible:** v1 graph files load fine, callers return empty until re-indexed +- Before: `dev refs "validateArgs"` → "No callers found" +- After: `dev refs "validateArgs"` → 5 adapter callers listed with file paths and line numbers + +--- + ## v0.12.1 — Cleaner Search Output + refs Fix *April 1, 2026*