@mishasinitcyn/betterrank 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,8 +25,17 @@ betterrank outline src/auth.py authenticate_user
25
25
  # Search for symbols by name or parameter
26
26
  betterrank search auth --root /path/to/project
27
27
 
28
- # Who calls this function?
29
- betterrank callers authenticateUser --root /path/to/project
28
+ # Who calls this function? (with call site context)
29
+ betterrank callers authenticateUser --root /path/to/project --context
30
+
31
+ # Everything about a function: source, types, deps, callers
32
+ betterrank context calculate_bid --root /path/to/project
33
+
34
+ # Trace the full call chain from entry point to function
35
+ betterrank trace calculate_bid --root /path/to/project
36
+
37
+ # What symbols changed and what might break?
38
+ betterrank diff --root /path/to/project
30
39
 
31
40
  # What depends on this file?
32
41
  betterrank dependents src/auth/handlers.ts --root /path/to/project
@@ -65,11 +74,14 @@ betterrank outline src/auth.py authenticate_user
65
74
  # Expand multiple symbols
66
75
  betterrank outline src/auth.py validate,process
67
76
 
77
+ # Show caller counts next to each function (requires --root)
78
+ betterrank outline src/auth.py --annotate --root ./backend
79
+
68
80
  # Resolve path relative to a root
69
81
  betterrank outline src/auth.py --root ./backend
70
82
  ```
71
83
 
72
- **Example output:**
84
+ **Example output (with `--annotate`):**
73
85
  ```
74
86
  1│ from fastapi import APIRouter, Depends
75
87
  2│ from core.auth import verify_auth
@@ -78,14 +90,14 @@ betterrank outline src/auth.py --root ./backend
78
90
  5│
79
91
  6│ @router.get("/users")
80
92
  7│ async def list_users(db = Depends(get_db)):
81
- │ ... (25 lines)
93
+ │ ... (25 lines) ← 2 callers
82
94
  33│
83
95
  34│ @router.post("/users")
84
96
  35│ async def create_user(data: UserCreate, db = Depends(get_db)):
85
- │ ... (40 lines)
97
+ │ ... (40 lines) ← 5 callers
86
98
  ```
87
99
 
88
- Typical compression: **3-5x** (a 2000-line file becomes ~400 lines of outline).
100
+ Typical compression: **3-5x** (a 2000-line file becomes ~400 lines of outline). Annotations show how many *external* files reference each function — instantly see what's critical vs dead code.
89
101
 
90
102
  ### `map` — Repo map
91
103
 
@@ -119,7 +131,99 @@ betterrank symbols --root /path/to/project --kind function
119
131
  ### `callers` — Who calls this symbol
120
132
 
121
133
  ```bash
134
+ # File names only
122
135
  betterrank callers authenticateUser --root /path/to/project
136
+
137
+ # With call site context lines (default 2 lines around each site)
138
+ betterrank callers authenticateUser --root /path/to/project --context
139
+
140
+ # Custom context window
141
+ betterrank callers authenticateUser --root /path/to/project --context 3
142
+ ```
143
+
144
+ **Example output (with `--context`):**
145
+ ```
146
+ src/engine/pipeline.py:
147
+ 16│ from app.engine.bidding import run_auction
148
+ 17│
149
+
150
+ 153│ return await run_auction(
151
+ 154│ searched=campaigns,
152
+ 155│ publisher=config.publisher,
153
+
154
+ src/api/serve.py:
155
+ 145│ bid = await run_auction(searched, publisher=pub_id)
156
+ ```
157
+
158
+ ### `context` — Full function context in one shot
159
+
160
+ Everything you need to understand a function: its source, the types in its signature (expanded inline), the functions it calls (with signatures), and a callers summary. Eliminates the multi-command chase.
161
+
162
+ ```bash
163
+ betterrank context calculate_bid --root /path/to/project
164
+
165
+ # Skip the source, just show deps/types/callers
166
+ betterrank context calculate_bid --root /path/to/project --no-source
167
+ ```
168
+
169
+ **Example output (`--no-source`):**
170
+ ```
171
+ ── calculate_bid (src/engine/bidding.py:489-718) ──
172
+
173
+ Types:
174
+ AuctionData2 (src/engine/bidding.py:74)
175
+ publisher_id: str
176
+ campaign_metrics: Dict[str, CampaignMetrics]
177
+ time_remaining_pct: float
178
+
179
+ References:
180
+ [function] def from_microdollars(microdollars) -> Decimal (src/core/currency.py:108)
181
+ [function] def get_config() -> ValuePredictorConfig (src/engine/predictor/config.py:316)
182
+ [function] def get_value_predictor() -> ValuePredictor (src/engine/predictor/persistence.py:123)
183
+ [class] class ValuePredictor (src/engine/predictor/predictor.py:43)
184
+
185
+ Callers (1 file):
186
+ src/engine/bidding.py
187
+ ```
188
+
189
+ ### `trace` — Recursive caller chain
190
+
191
+ Walk UP the call graph from a symbol to see the full path from entry points to your function. At each hop, resolves which function in the caller file contains the call site.
192
+
193
+ ```bash
194
+ betterrank trace calculate_bid --root /path/to/project
195
+ betterrank trace calculate_bid --root /path/to/project --depth 5
196
+ ```
197
+
198
+ **Example output:**
199
+ ```
200
+ calculate_bid (src/engine/bidding.py:489)
201
+ ← run_auction (src/engine/bidding.py:833)
202
+ ← handle_request (src/engine/pipeline.py:153)
203
+ ← app (src/main.py:45)
204
+ ```
205
+
206
+ ### `diff` — Git-aware blast radius
207
+
208
+ Shows which symbols changed in the working tree and how many external files call each changed symbol. Compares current disk state against a git ref.
209
+
210
+ ```bash
211
+ # Uncommitted changes vs HEAD
212
+ betterrank diff --root /path/to/project
213
+
214
+ # Changes since a specific commit or branch
215
+ betterrank diff --ref main --root /path/to/project
216
+ betterrank diff --ref HEAD~5 --root /path/to/project
217
+ ```
218
+
219
+ **Example output:**
220
+ ```
221
+ src/engine/bidding.py:
222
+ ~ [function] calculate_bid (3 callers)
223
+ + [function] compute_value_multi
224
+ - [function] old_quality_score (1 caller)
225
+
226
+ ⚠ 4 external callers of modified/removed symbols
123
227
  ```
124
228
 
125
229
  ### `deps` — What does this file import
@@ -185,7 +289,8 @@ const idx = new CodeIndex('/path/to/project');
185
289
 
186
290
  const map = await idx.map({ limit: 100, focusFiles: ['src/main.ts'] });
187
291
  const results = await idx.search({ query: 'auth', kind: 'function', limit: 10 });
188
- const callers = await idx.callers({ symbol: 'authenticate' });
292
+ const callers = await idx.callers({ symbol: 'authenticate', context: 2 });
293
+ const counts = await idx.getCallerCounts('src/auth.ts');
189
294
  const deps = await idx.dependencies({ file: 'src/auth.ts' });
190
295
  const dependents = await idx.dependents({ file: 'src/auth.ts' });
191
296
  const hood = await idx.neighborhood({ file: 'src/auth.ts', hops: 2, maxFiles: 10 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mishasinitcyn/betterrank",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Structural code index with PageRank-ranked repo maps, symbol search, call-graph queries, and dependency analysis. Built on tree-sitter and graphology.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli.js CHANGED
@@ -12,12 +12,15 @@ betterrank <command> [options]
12
12
 
13
13
  Commands:
14
14
  ui [--port N] Launch web UI (default port: 3333)
15
- outline <file> [symbol1,symbol2] File skeleton with collapsed bodies
15
+ outline <file> [symbol1,symbol2] [--annotate] File skeleton (--annotate for caller counts)
16
16
  map [--focus file1,file2] Repo map (ranked by PageRank)
17
17
  search <query> [--kind type] Substring search on symbol names + signatures (ranked by PageRank)
18
18
  structure [--depth N] File tree with symbol counts (default depth: ${DEFAULT_DEPTH})
19
19
  symbols [--file path] [--kind type] List definitions (ranked by PageRank)
20
- callers <symbol> [--file path] All call sites (ranked by importance)
20
+ callers <symbol> [--file path] [--context] All call sites (ranked, with context lines)
21
+ context <symbol> [--file path] Full context: source, deps, types, callers
22
+ trace <symbol> [--depth N] Recursive caller chain (call tree)
23
+ diff [--ref <commit>] Git-aware blast radius (changed symbols + callers)
21
24
  deps <file> What this file imports (ranked)
22
25
  dependents <file> What imports this file (ranked)
23
26
  neighborhood <file> [--hops N] [--max-files N] Local subgraph (ranked by PageRank)
@@ -34,7 +37,7 @@ Global flags:
34
37
  `.trim();
35
38
 
36
39
  const COMMAND_HELP = {
37
- outline: `betterrank outline <file> [symbol1,symbol2,...] [--root <path>]
40
+ outline: `betterrank outline <file> [symbol1,symbol2,...] [--annotate --root <path>]
38
41
 
39
42
  View a file's structure with function/class bodies collapsed, or expand
40
43
  specific symbols to see their full source.
@@ -46,13 +49,15 @@ With symbol names (comma-separated): shows the full source of those
46
49
  specific functions/classes with line numbers.
47
50
 
48
51
  Options:
49
- --root <path> Resolve file path relative to this directory
52
+ --root <path> Resolve file path relative to this directory
53
+ --annotate Show caller counts next to each function (requires --root)
50
54
 
51
55
  Examples:
52
56
  betterrank outline src/auth.py
53
57
  betterrank outline src/auth.py authenticate_user
54
58
  betterrank outline src/auth.py validate,process
55
- betterrank outline src/handlers.ts --root ./backend`,
59
+ betterrank outline src/handlers.ts --root ./backend
60
+ betterrank outline src/auth.py --annotate --root ./backend`,
56
61
 
57
62
  map: `betterrank map [--focus file1,file2] [--root <path>]
58
63
 
@@ -116,19 +121,71 @@ Examples:
116
121
  betterrank symbols --file src/auth/handlers.ts --root ./backend
117
122
  betterrank symbols --kind class --root . --limit 20`,
118
123
 
119
- callers: `betterrank callers <symbol> [--file path] [--root <path>]
124
+ callers: `betterrank callers <symbol> [--file path] [--context [N]] [--root <path>]
120
125
 
121
126
  Find all files that reference a symbol. Ranked by file-level PageRank.
122
127
 
123
128
  Options:
124
129
  --file <path> Disambiguate when multiple symbols share a name
130
+ --context [N] Show N lines of context around each call site (default: 2)
125
131
  --count Return count only
126
132
  --offset N Skip first N results
127
133
  --limit N Max results (default: ${DEFAULT_LIMIT})
128
134
 
129
135
  Examples:
130
136
  betterrank callers authenticateUser --root ./backend
131
- betterrank callers resolve --file src/utils.ts --root .`,
137
+ betterrank callers authenticateUser --root ./backend --context
138
+ betterrank callers resolve --file src/utils.ts --root . --context 3`,
139
+
140
+ context: `betterrank context <symbol> [--file path] [--root <path>]
141
+
142
+ Everything you need to understand a function in one shot.
143
+
144
+ Shows: the function's source, signatures of all functions/types it references,
145
+ expanded type definitions from the signature, and a callers summary.
146
+
147
+ Eliminates the multi-command chase of: outline → expand → search types → callers.
148
+
149
+ Options:
150
+ --file <path> Disambiguate when multiple symbols share a name
151
+ --no-source Skip the function source (show only deps/types/callers)
152
+
153
+ Examples:
154
+ betterrank context calculate_bid --root .
155
+ betterrank context Router --file src/llm.py --root .`,
156
+
157
+ trace: `betterrank trace <symbol> [--depth N] [--file path] [--root <path>]
158
+
159
+ Recursive caller chain — walk UP the call graph from a symbol to see
160
+ the full path from entry points to your function.
161
+
162
+ At each hop, resolves which function in the caller file contains the
163
+ call site. Displays as an indented tree.
164
+
165
+ Options:
166
+ --depth N Max hops upward (default: 3)
167
+ --file <path> Disambiguate when multiple symbols share a name
168
+
169
+ Examples:
170
+ betterrank trace calculate_bid --root .
171
+ betterrank trace send_to_firehose --root . --depth 4
172
+ betterrank trace authenticate --file src/auth.ts --root .`,
173
+
174
+ diff: `betterrank diff [--ref <commit>] [--root <path>]
175
+
176
+ Git-aware blast radius — shows which symbols changed in the working tree
177
+ and how many external files call each changed symbol.
178
+
179
+ Compares current files on disk against the indexed state. Shows added,
180
+ removed, and modified symbols with their caller counts.
181
+
182
+ Options:
183
+ --ref <commit> Git ref to diff against (default: HEAD)
184
+
185
+ Examples:
186
+ betterrank diff --root .
187
+ betterrank diff --ref main --root .
188
+ betterrank diff --ref HEAD~3 --root .`,
132
189
 
133
190
  deps: `betterrank deps <file> [--root <path>]
134
191
 
@@ -252,14 +309,20 @@ async function main() {
252
309
  return; // Keep process alive (server is listening)
253
310
  }
254
311
 
255
- // Outline command — standalone, no CodeIndex needed
312
+ // Outline command — standalone by default, needs CodeIndex for --annotate
256
313
  if (command === 'outline') {
257
314
  const filePath = flags._positional[0];
258
315
  if (!filePath) {
259
- console.error('Usage: betterrank outline <file> [symbol1,symbol2]');
316
+ console.error('Usage: betterrank outline <file> [symbol1,symbol2] [--annotate --root <path>]');
260
317
  process.exit(1);
261
318
  }
262
319
  const expandSymbols = flags._positional[1] ? flags._positional[1].split(',') : [];
320
+ const annotate = flags.annotate === true;
321
+
322
+ if (annotate && !flags.root) {
323
+ console.error('outline --annotate requires --root for graph data');
324
+ process.exit(1);
325
+ }
263
326
 
264
327
  const root = flags.root ? resolve(flags.root) : process.cwd();
265
328
  const absPath = isAbsolute(filePath) ? filePath : resolve(root, filePath);
@@ -275,7 +338,14 @@ async function main() {
275
338
 
276
339
  const relPath = relative(root, absPath);
277
340
  const { buildOutline } = await import('./outline.js');
278
- const result = buildOutline(source, relPath, expandSymbols);
341
+
342
+ let callerCounts;
343
+ if (annotate) {
344
+ const idx = new CodeIndex(resolve(flags.root));
345
+ callerCounts = await idx.getCallerCounts(relPath);
346
+ }
347
+
348
+ const result = buildOutline(source, relPath, expandSymbols, { callerCounts });
279
349
  console.log(result);
280
350
  return;
281
351
  }
@@ -421,9 +491,29 @@ async function main() {
421
491
  const symbol = flags._positional[0];
422
492
  if (!symbol) { console.error('Usage: betterrank callers <symbol> [--file path]'); process.exit(1); }
423
493
  const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
424
- const result = await idx.callers({ symbol, file: normalizeFilePath(flags.file), count: countMode, offset, limit: effectiveLimit });
494
+ const contextLines = flags.context === true ? 2 : (flags.context ? parseInt(flags.context, 10) : 0);
495
+ const result = await idx.callers({ symbol, file: normalizeFilePath(flags.file), count: countMode, offset, limit: effectiveLimit, context: contextLines });
425
496
  if (countMode) {
426
497
  console.log(`total: ${result.total}`);
498
+ } else if (contextLines > 0) {
499
+ // Rich output with call-site context
500
+ const pad = 6;
501
+ for (const c of result) {
502
+ console.log(`${c.file}:`);
503
+ if (c.sites && c.sites.length > 0) {
504
+ for (const site of c.sites) {
505
+ for (const t of site.text) {
506
+ console.log(` ${String(t.line).padStart(pad)}│ ${t.content}`);
507
+ }
508
+ console.log('');
509
+ }
510
+ } else {
511
+ console.log(' (no call sites found in source)\n');
512
+ }
513
+ }
514
+ if (result.length === 0) {
515
+ console.log('(no callers found)');
516
+ }
427
517
  } else {
428
518
  for (const c of result) {
429
519
  console.log(c.file);
@@ -437,6 +527,116 @@ async function main() {
437
527
  break;
438
528
  }
439
529
 
530
+ case 'context': {
531
+ const symbol = flags._positional[0];
532
+ if (!symbol) { console.error('Usage: betterrank context <symbol> [--file path]'); process.exit(1); }
533
+ const noSource = flags['no-source'] === true;
534
+ const result = await idx.context({ symbol, file: normalizeFilePath(flags.file) });
535
+ if (!result) {
536
+ console.log(`(symbol "${symbol}" not found)`);
537
+ break;
538
+ }
539
+
540
+ const def = result.definition;
541
+ const pad = Math.max(String(def.lineEnd).length, 4);
542
+
543
+ // Header
544
+ console.log(`── ${def.name} (${def.file}:${def.lineStart}-${def.lineEnd}) ──`);
545
+ console.log('');
546
+
547
+ // Source (or just the signature in --no-source mode)
548
+ if (!noSource) {
549
+ for (let i = 0; i < def.source.length; i++) {
550
+ console.log(` ${String(def.lineStart + i).padStart(pad)}│ ${def.source[i]}`);
551
+ }
552
+ console.log('');
553
+ } else if (def.signature) {
554
+ console.log(` ${def.signature}`);
555
+ console.log('');
556
+ }
557
+
558
+ // Type references from signature
559
+ if (result.typeRefs.length > 0) {
560
+ console.log('Types:');
561
+ for (const t of result.typeRefs) {
562
+ console.log(` ${t.name} (${t.file}:${t.lineStart})`);
563
+ if (t.fields) {
564
+ for (const line of t.fields) {
565
+ console.log(` ${line}`);
566
+ }
567
+ }
568
+ console.log('');
569
+ }
570
+ }
571
+
572
+ // Used symbols (functions/types referenced in the body)
573
+ if (result.usedSymbols.length > 0) {
574
+ console.log('References:');
575
+ for (const s of result.usedSymbols) {
576
+ const loc = s.file === def.file ? `line ${s.lineStart}` : `${s.file}:${s.lineStart}`;
577
+ console.log(` [${s.kind}] ${s.signature || s.name} (${loc})`);
578
+ }
579
+ console.log('');
580
+ }
581
+
582
+ // Callers
583
+ if (result.callers.length > 0) {
584
+ console.log(`Callers (${result.callers.length} file${result.callers.length === 1 ? '' : 's'}):`);
585
+ for (const f of result.callers) {
586
+ console.log(` ${f}`);
587
+ }
588
+ } else {
589
+ console.log('Callers: (none detected)');
590
+ }
591
+ break;
592
+ }
593
+
594
+ case 'trace': {
595
+ const symbol = flags._positional[0];
596
+ if (!symbol) { console.error('Usage: betterrank trace <symbol> [--depth N]'); process.exit(1); }
597
+ const traceDepth = flags.depth ? parseInt(flags.depth, 10) : 3;
598
+ const tree = await idx.trace({ symbol, file: normalizeFilePath(flags.file), depth: traceDepth });
599
+ if (!tree) {
600
+ console.log(`(symbol "${symbol}" not found)`);
601
+ } else {
602
+ const printNode = (node, depth) => {
603
+ const indent = depth === 0 ? '' : ' '.repeat(depth) + '← ';
604
+ const loc = `(${node.file}:${node.line || '?'})`;
605
+ console.log(`${indent}${node.name} ${loc}`);
606
+ for (const caller of node.callers) {
607
+ printNode(caller, depth + 1);
608
+ }
609
+ };
610
+ printNode(tree, 0);
611
+ }
612
+ break;
613
+ }
614
+
615
+ case 'diff': {
616
+ const result = await idx.diff({ ref: flags.ref || 'HEAD' });
617
+ if (result.error) {
618
+ console.error(result.error);
619
+ break;
620
+ }
621
+ if (result.changed.length === 0) {
622
+ console.log('(no symbol changes detected)');
623
+ break;
624
+ }
625
+ for (const entry of result.changed) {
626
+ console.log(`${entry.file}:`);
627
+ for (const s of entry.symbols) {
628
+ const tag = s.change === 'added' ? '+' : s.change === 'removed' ? '-' : '~';
629
+ const callerNote = s.callerCount > 0 ? ` (${s.callerCount} caller${s.callerCount === 1 ? '' : 's'})` : '';
630
+ console.log(` ${tag} [${s.kind}] ${s.name}${callerNote}`);
631
+ }
632
+ console.log('');
633
+ }
634
+ if (result.totalCallers > 0) {
635
+ console.log(`⚠ ${result.totalCallers} external caller${result.totalCallers === 1 ? '' : 's'} of modified/removed symbols`);
636
+ }
637
+ break;
638
+ }
639
+
440
640
  case 'deps': {
441
641
  const file = normalizeFilePath(flags._positional[0]);
442
642
  if (!file) { console.error('Usage: betterrank deps <file>'); process.exit(1); }
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ import { readFile } from 'fs/promises';
2
2
  import { join, dirname, relative, sep, basename } from 'path';
3
3
  import { CodeIndexCache } from './cache.js';
4
4
  import { rankedSymbols } from './graph.js';
5
+ import { parseFile } from './parser.js';
5
6
 
6
7
  // ── Orphan false-positive filters ──────────────────────────────────────────
7
8
  //
@@ -457,15 +458,19 @@ class CodeIndex {
457
458
  * Results are ranked by file-level PageRank (most important callers first).
458
459
  * Supports offset/limit pagination and count-only mode.
459
460
  *
461
+ * When `context` > 0, reads each caller file from disk and extracts
462
+ * the actual call-site lines with surrounding context.
463
+ *
460
464
  * @param {object} opts
461
465
  * @param {string} opts.symbol - Symbol name
462
466
  * @param {string} [opts.file] - Disambiguate by file
467
+ * @param {number} [opts.context=0] - Lines of context around each call site (0 = off)
463
468
  * @param {number} [opts.offset] - Skip first N results
464
469
  * @param {number} [opts.limit] - Max results to return
465
470
  * @param {boolean} [opts.count] - If true, return only { total }
466
- * @returns {Array<{file}>|{total: number}}
471
+ * @returns {Array<{file, sites?}>|{total: number}}
467
472
  */
468
- async callers({ symbol, file, offset, limit, count = false }) {
473
+ async callers({ symbol, file, offset, limit, count = false, context = 0 }) {
469
474
  await this._ensureReady();
470
475
  const graph = this.cache.getGraph();
471
476
  if (!graph) return count ? { total: 0 } : [];
@@ -499,7 +504,56 @@ class CodeIndex {
499
504
  for (const r of results) delete r._score;
500
505
 
501
506
  if (count) return { total: results.length };
502
- return paginate(results, { offset, limit }).items;
507
+
508
+ const paged = paginate(results, { offset, limit }).items;
509
+
510
+ if (context > 0) {
511
+ // Match call sites: symbol followed by ( — avoids string literals and definitions
512
+ const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
513
+ const callPattern = new RegExp(`(?<![a-zA-Z0-9_])${escaped}\\s*\\(`);
514
+ // Fallback: import/from lines that reference the symbol
515
+ const importPattern = new RegExp(`(?:import|from)\\s.*\\b${escaped}\\b`);
516
+
517
+ // Collect the target symbol's own definition ranges to exclude
518
+ const defRanges = [];
519
+ for (const tk of targetKeys) {
520
+ try {
521
+ const attrs = graph.getNodeAttributes(tk);
522
+ if (attrs.type === 'symbol') defRanges.push({ file: attrs.file, start: attrs.lineStart, end: attrs.lineEnd });
523
+ } catch { /* skip */ }
524
+ }
525
+
526
+ for (const entry of paged) {
527
+ entry.sites = [];
528
+ try {
529
+ const absPath = join(this.projectRoot, entry.file);
530
+ const source = await readFile(absPath, 'utf-8');
531
+ const lines = source.split('\n');
532
+
533
+ for (let i = 0; i < lines.length; i++) {
534
+ const lineNum = i + 1;
535
+ // Skip lines inside the symbol's own definition
536
+ const inDef = defRanges.some(r => r.file === entry.file && lineNum >= r.start && lineNum <= r.end);
537
+ if (inDef) continue;
538
+
539
+ const line = lines[i];
540
+ if (!callPattern.test(line) && !importPattern.test(line)) continue;
541
+
542
+ const start = Math.max(0, i - context);
543
+ const end = Math.min(lines.length - 1, i + context);
544
+ const text = [];
545
+ for (let j = start; j <= end; j++) {
546
+ text.push({ line: j + 1, content: lines[j] });
547
+ }
548
+ entry.sites.push({ line: lineNum, text });
549
+ }
550
+ } catch {
551
+ // File unreadable — skip context for this caller
552
+ }
553
+ }
554
+ }
555
+
556
+ return paged;
503
557
  }
504
558
 
505
559
  /**
@@ -908,6 +962,479 @@ class CodeIndex {
908
962
  return { nodes, edges };
909
963
  }
910
964
 
965
+ /**
966
+ * Get caller counts for all symbols defined in a file.
967
+ * Returns a Map<symbolName, number> where the count is unique
968
+ * files that reference each symbol (excluding self-references).
969
+ *
970
+ * @param {string} file - Relative file path
971
+ * @returns {Map<string, number>}
972
+ */
973
+ async getCallerCounts(file) {
974
+ await this._ensureReady();
975
+ const graph = this.cache.getGraph();
976
+ if (!graph) return new Map();
977
+
978
+ const counts = new Map();
979
+ graph.forEachNode((node, attrs) => {
980
+ if (attrs.type !== 'symbol') return;
981
+ if (attrs.file !== file) return;
982
+
983
+ const callerFiles = new Set();
984
+ graph.forEachInEdge(node, (_edge, edgeAttrs, source) => {
985
+ if (edgeAttrs.type !== 'REFERENCES') return;
986
+ const sourceAttrs = graph.getNodeAttributes(source);
987
+ const sourceFile = sourceAttrs.file || source;
988
+ if (sourceFile !== file) callerFiles.add(sourceFile);
989
+ });
990
+
991
+ if (callerFiles.size > 0) {
992
+ counts.set(attrs.name, callerFiles.size);
993
+ }
994
+ });
995
+
996
+ return counts;
997
+ }
998
+
999
+ /**
1000
+ * One-shot context: everything needed to understand a function.
1001
+ *
1002
+ * Returns the function's source, the signatures of functions/types it
1003
+ * references, and a callers summary — all from a single command.
1004
+ *
1005
+ * @param {object} opts
1006
+ * @param {string} opts.symbol - Symbol name
1007
+ * @param {string} [opts.file] - Disambiguate by file
1008
+ * @returns {object|null} { definition, usedSymbols, typeRefs, callers }
1009
+ */
1010
+ async context({ symbol, file }) {
1011
+ await this._ensureReady();
1012
+ const graph = this.cache.getGraph();
1013
+ if (!graph) return null;
1014
+
1015
+ // Find the target symbol (highest PageRank if multiple)
1016
+ const candidates = [];
1017
+ graph.forEachNode((node, attrs) => {
1018
+ if (attrs.type !== 'symbol') return;
1019
+ if (attrs.name !== symbol) return;
1020
+ if (file && attrs.file !== file) return;
1021
+ candidates.push({ key: node, ...attrs });
1022
+ });
1023
+ if (candidates.length === 0) return null;
1024
+
1025
+ const ranked = this._getRanked();
1026
+ const scoreMap = new Map(ranked);
1027
+ candidates.sort((a, b) => (scoreMap.get(b.key) || 0) - (scoreMap.get(a.key) || 0));
1028
+ const target = candidates[0];
1029
+
1030
+ // Read the source file
1031
+ let source, lines;
1032
+ try {
1033
+ const absPath = join(this.projectRoot, target.file);
1034
+ source = await readFile(absPath, 'utf-8');
1035
+ lines = source.split('\n');
1036
+ } catch {
1037
+ return null;
1038
+ }
1039
+
1040
+ // Extract the function body text
1041
+ const bodyLines = lines.slice(target.lineStart - 1, target.lineEnd);
1042
+ const bodyText = bodyLines.join('\n');
1043
+
1044
+ // Build a set of all symbol names in the graph (for matching)
1045
+ const allSymbols = new Map(); // name -> [{ file, kind, signature, lineStart }]
1046
+ graph.forEachNode((node, attrs) => {
1047
+ if (attrs.type !== 'symbol') return;
1048
+ if (attrs.file === target.file && attrs.name === target.name) return; // skip self
1049
+ if (!allSymbols.has(attrs.name)) allSymbols.set(attrs.name, []);
1050
+ allSymbols.get(attrs.name).push({
1051
+ file: attrs.file,
1052
+ kind: attrs.kind,
1053
+ signature: attrs.signature,
1054
+ lineStart: attrs.lineStart,
1055
+ lineEnd: attrs.lineEnd,
1056
+ });
1057
+ });
1058
+
1059
+ // Find symbols referenced in the body
1060
+ // Use word-boundary matching for each known symbol name
1061
+ // Skip very common names that cause false positives
1062
+ const NOISE_NAMES = new Set([
1063
+ 'get', 'set', 'put', 'post', 'delete', 'head', 'patch',
1064
+ 'start', 'stop', 'run', 'main', 'init', 'setup', 'close',
1065
+ 'dict', 'list', 'str', 'int', 'bool', 'float', 'type',
1066
+ 'key', 'value', 'name', 'data', 'config', 'result', 'error',
1067
+ 'test', 'self', 'cls', 'app', 'log', 'logger',
1068
+ 'enabled', 'default', 'constructor', 'length', 'size',
1069
+ 'fetch', 'send', 'table', 'one', 'append', 'write', 'read',
1070
+ 'update', 'create', 'find', 'add', 'remove', 'index', 'map',
1071
+ 'filter', 'sort', 'join', 'split', 'trim', 'replace',
1072
+ 'push', 'pop', 'shift', 'reduce', 'keys', 'values', 'items',
1073
+ 'search', 'match', 'query', 'count', 'call', 'apply', 'bind',
1074
+ ]);
1075
+ const usedSymbols = [];
1076
+ const seen = new Set();
1077
+ for (const [name, defs] of allSymbols) {
1078
+ if (name.length < 3) continue; // skip very short names
1079
+ if (NOISE_NAMES.has(name)) continue;
1080
+ if (seen.has(name)) continue;
1081
+ const pattern = new RegExp(`(?<![a-zA-Z0-9_])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-zA-Z0-9_])`);
1082
+ if (pattern.test(bodyText)) {
1083
+ seen.add(name);
1084
+ // Pick the best definition (same-file first, then highest PageRank)
1085
+ const sameFile = defs.find(d => d.file === target.file);
1086
+ const best = sameFile || defs[0];
1087
+ usedSymbols.push({ name, ...best });
1088
+ }
1089
+ }
1090
+ // Sort: functions first, then types, then by name
1091
+ const kindOrder = { function: 0, class: 1, type: 2, variable: 3 };
1092
+ usedSymbols.sort((a, b) => (kindOrder[a.kind] ?? 9) - (kindOrder[b.kind] ?? 9) || a.name.localeCompare(b.name));
1093
+
1094
+ // Resolve type annotations in the signature
1095
+ // Extract type-like tokens from the signature (capitalized words, common patterns)
1096
+ const typeRefs = [];
1097
+ const sigText = target.signature || '';
1098
+ const typePattern = /(?<![a-zA-Z0-9_])([A-Z][a-zA-Z0-9_]+)(?![a-zA-Z0-9_])/g;
1099
+ const seenTypes = new Set();
1100
+ let match;
1101
+ while ((match = typePattern.exec(sigText)) !== null) {
1102
+ const typeName = match[1];
1103
+ if (seenTypes.has(typeName)) continue;
1104
+ seenTypes.add(typeName);
1105
+ const typeDefs = allSymbols.get(typeName);
1106
+ if (!typeDefs) continue;
1107
+ // Find the type definition and get its fields (expand its body)
1108
+ const best = typeDefs.find(d => d.file === target.file) || typeDefs[0];
1109
+ if (best.kind === 'class' || best.kind === 'type') {
1110
+ let fields = null;
1111
+ try {
1112
+ const typeAbsPath = join(this.projectRoot, best.file);
1113
+ const typeSource = await readFile(typeAbsPath, 'utf-8');
1114
+ const typeLines = typeSource.split('\n');
1115
+ // Extract the body lines (up to 15 lines to keep it compact)
1116
+ const maxPreview = 15;
1117
+ const bodyStart = best.lineStart; // 1-indexed
1118
+ const bodyEnd = Math.min(best.lineEnd, best.lineStart + maxPreview - 1);
1119
+ fields = typeLines.slice(bodyStart - 1, bodyEnd).map(l => l);
1120
+ if (best.lineEnd > bodyEnd) {
1121
+ fields.push(` ... (${best.lineEnd - bodyEnd} more lines)`);
1122
+ }
1123
+ } catch { /* skip */ }
1124
+ typeRefs.push({ name: typeName, file: best.file, lineStart: best.lineStart, kind: best.kind, fields });
1125
+ }
1126
+ }
1127
+
1128
+ // Get callers (external files only) — union across ALL same-name nodes
1129
+ // to handle ambiguous graph resolution (same name in different files)
1130
+ const callerFiles = new Set();
1131
+ for (const c of candidates) {
1132
+ graph.forEachInEdge(c.key, (_edge, attrs, src) => {
1133
+ if (attrs.type !== 'REFERENCES') return;
1134
+ const srcAttrs = graph.getNodeAttributes(src);
1135
+ const sf = srcAttrs.file || src;
1136
+ if (sf !== target.file) callerFiles.add(sf);
1137
+ });
1138
+ }
1139
+
1140
+ // Remove symbols already shown in typeRefs to avoid redundancy
1141
+ const typeRefNames = new Set(typeRefs.map(t => t.name));
1142
+ const filteredSymbols = usedSymbols.filter(s => !typeRefNames.has(s.name));
1143
+
1144
+ return {
1145
+ definition: {
1146
+ name: target.name,
1147
+ kind: target.kind,
1148
+ file: target.file,
1149
+ lineStart: target.lineStart,
1150
+ lineEnd: target.lineEnd,
1151
+ signature: target.signature,
1152
+ source: bodyLines,
1153
+ },
1154
+ usedSymbols: filteredSymbols,
1155
+ typeRefs,
1156
+ callers: [...callerFiles].sort(),
1157
+ };
1158
+ }
1159
+
1160
+ /**
1161
+ * Recursive caller chain — walk UP the call graph from a symbol.
1162
+ * At each hop, resolves which function in the caller file contains
1163
+ * the call site by cross-referencing line numbers with definitions.
1164
+ *
1165
+ * Returns a tree: { name, file, line, callers: [...] }
1166
+ *
1167
+ * @param {object} opts
1168
+ * @param {string} opts.symbol - Starting symbol name
1169
+ * @param {string} [opts.file] - Disambiguate by file
1170
+ * @param {number} [opts.depth=3] - Max hops upward
1171
+ * @returns {object} Tree root node
1172
+ */
1173
+ async trace({ symbol, file, depth = 3 }) {
1174
+ await this._ensureReady();
1175
+ const graph = this.cache.getGraph();
1176
+ if (!graph) return null;
1177
+
1178
+ // Find the target symbol node(s)
1179
+ const targetKeys = [];
1180
+ graph.forEachNode((node, attrs) => {
1181
+ if (attrs.type !== 'symbol') return;
1182
+ if (attrs.name !== symbol) return;
1183
+ if (file && attrs.file !== file) return;
1184
+ targetKeys.push(node);
1185
+ });
1186
+
1187
+ if (targetKeys.length === 0) return null;
1188
+
1189
+ // Use the first match (highest PageRank if multiple)
1190
+ const ranked = this._getRanked();
1191
+ const scoreMap = new Map(ranked);
1192
+ targetKeys.sort((a, b) => (scoreMap.get(b) || 0) - (scoreMap.get(a) || 0));
1193
+ const rootKey = targetKeys[0];
1194
+ const rootAttrs = graph.getNodeAttributes(rootKey);
1195
+
1196
+ // Cache of file -> definitions (avoid re-parsing the same file)
1197
+ const defCache = new Map();
1198
+
1199
+ const getFileDefs = async (filePath) => {
1200
+ if (defCache.has(filePath)) return defCache.get(filePath);
1201
+ try {
1202
+ const absPath = join(this.projectRoot, filePath);
1203
+ const source = await readFile(absPath, 'utf-8');
1204
+ const parsed = parseFile(filePath, source);
1205
+ const defs = parsed ? parsed.definitions.sort((a, b) => a.lineStart - b.lineStart) : [];
1206
+ defCache.set(filePath, defs);
1207
+ return defs;
1208
+ } catch {
1209
+ defCache.set(filePath, []);
1210
+ return [];
1211
+ }
1212
+ };
1213
+
1214
+ // Find which definition in a file contains a given line
1215
+ const findContainingDef = (defs, line) => {
1216
+ for (let i = defs.length - 1; i >= 0; i--) {
1217
+ if (line >= defs[i].lineStart && line <= defs[i].lineEnd) return defs[i];
1218
+ }
1219
+ return null;
1220
+ };
1221
+
1222
+ // BFS upward through callers
1223
+ const visited = new Set(); // "file::symbol" keys to prevent cycles
1224
+
1225
+ const buildNode = async (symbolName, symbolFile, symbolLine, currentDepth) => {
1226
+ const nodeKey = `${symbolFile}::${symbolName}`;
1227
+ const node = { name: symbolName, file: symbolFile, line: symbolLine, callers: [] };
1228
+
1229
+ if (currentDepth >= depth) return node;
1230
+ if (visited.has(nodeKey)) return node;
1231
+ visited.add(nodeKey);
1232
+
1233
+ // Find this symbol in the graph
1234
+ const symKeys = [];
1235
+ graph.forEachNode((gNode, attrs) => {
1236
+ if (attrs.type !== 'symbol') return;
1237
+ if (attrs.name !== symbolName) return;
1238
+ if (attrs.file !== symbolFile) return;
1239
+ symKeys.push(gNode);
1240
+ });
1241
+
1242
+ // Collect caller files
1243
+ const callerFiles = new Set();
1244
+ for (const sk of symKeys) {
1245
+ graph.forEachInEdge(sk, (_edge, attrs, source) => {
1246
+ if (attrs.type !== 'REFERENCES') return;
1247
+ const sourceAttrs = graph.getNodeAttributes(source);
1248
+ const sf = sourceAttrs.file || source;
1249
+ if (sf !== symbolFile) callerFiles.add(sf);
1250
+ });
1251
+ }
1252
+
1253
+ // For each caller file, find which function contains the call
1254
+ const callPattern = new RegExp(
1255
+ `(?<![a-zA-Z0-9_])${symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`
1256
+ );
1257
+
1258
+ for (const callerFile of callerFiles) {
1259
+ try {
1260
+ const absPath = join(this.projectRoot, callerFile);
1261
+ const source = await readFile(absPath, 'utf-8');
1262
+ const lines = source.split('\n');
1263
+ const defs = await getFileDefs(callerFile);
1264
+
1265
+ // Find the first call site line
1266
+ let callLine = null;
1267
+ for (let i = 0; i < lines.length; i++) {
1268
+ if (callPattern.test(lines[i])) { callLine = i + 1; break; }
1269
+ }
1270
+
1271
+ // Resolve to containing function
1272
+ const containingDef = callLine ? findContainingDef(defs, callLine) : null;
1273
+
1274
+ if (containingDef) {
1275
+ const callerNode = await buildNode(containingDef.name, callerFile, containingDef.lineStart, currentDepth + 1);
1276
+ node.callers.push(callerNode);
1277
+ } else {
1278
+ // Top-level call (not inside any function) — show as file-level
1279
+ node.callers.push({ name: `<module>`, file: callerFile, line: callLine, callers: [] });
1280
+ }
1281
+ } catch {
1282
+ // Skip unreadable files
1283
+ }
1284
+ }
1285
+
1286
+ // Sort callers by file for deterministic output
1287
+ node.callers.sort((a, b) => a.file.localeCompare(b.file));
1288
+ return node;
1289
+ };
1290
+
1291
+ return buildNode(rootAttrs.name, rootAttrs.file, rootAttrs.lineStart, 0);
1292
+ }
1293
+
1294
+ /**
1295
+ * Git-aware blast radius — what symbols changed and who calls them.
1296
+ *
1297
+ * Compares the working tree (or a git ref) against the index to find
1298
+ * added, removed, and modified symbols, then looks up their callers.
1299
+ *
1300
+ * @param {object} [opts]
1301
+ * @param {string} [opts.ref] - Git ref to diff against (default: HEAD)
1302
+ * @returns {{ changed: Array<{file, symbols: Array<{name, kind, change, callerCount}>}>, totalCallers: number }}
1303
+ */
1304
+ async diff({ ref = 'HEAD' } = {}) {
1305
+ await this._ensureReady();
1306
+ const graph = this.cache.getGraph();
1307
+ if (!graph) return { changed: [], totalCallers: 0 };
1308
+
1309
+ // Get changed files from git
1310
+ const { execSync } = await import('child_process');
1311
+ let changedFiles;
1312
+ try {
1313
+ const output = execSync(`git diff --name-only ${ref}`, {
1314
+ cwd: this.projectRoot,
1315
+ encoding: 'utf-8',
1316
+ timeout: 10000,
1317
+ }).trim();
1318
+ if (!output) return { changed: [], totalCallers: 0 };
1319
+ changedFiles = output.split('\n').filter(Boolean);
1320
+ } catch {
1321
+ return { changed: [], totalCallers: 0, error: 'git diff failed — is this a git repo?' };
1322
+ }
1323
+
1324
+ // Also include untracked new files
1325
+ try {
1326
+ const untracked = execSync('git ls-files --others --exclude-standard', {
1327
+ cwd: this.projectRoot,
1328
+ encoding: 'utf-8',
1329
+ timeout: 10000,
1330
+ }).trim();
1331
+ if (untracked) {
1332
+ for (const f of untracked.split('\n').filter(Boolean)) {
1333
+ if (!changedFiles.includes(f)) changedFiles.push(f);
1334
+ }
1335
+ }
1336
+ } catch { /* ignore */ }
1337
+
1338
+ const results = [];
1339
+ let totalCallers = 0;
1340
+
1341
+ for (const filePath of changedFiles) {
1342
+ // Get OLD symbols from git ref
1343
+ const oldSymbols = new Map();
1344
+ try {
1345
+ const oldSource = execSync(`git show ${ref}:${filePath}`, {
1346
+ cwd: this.projectRoot,
1347
+ encoding: 'utf-8',
1348
+ stdio: ['pipe', 'pipe', 'pipe'],
1349
+ timeout: 10000,
1350
+ });
1351
+ const oldParsed = parseFile(filePath, oldSource);
1352
+ if (oldParsed) {
1353
+ for (const def of oldParsed.definitions) {
1354
+ oldSymbols.set(def.name, { kind: def.kind, signature: def.signature });
1355
+ }
1356
+ }
1357
+ } catch {
1358
+ // File is new (doesn't exist in ref) — all symbols are "added"
1359
+ }
1360
+
1361
+ // Get graph keys for caller lookups on current symbols
1362
+ const graphKeys = new Map();
1363
+ graph.forEachNode((node, attrs) => {
1364
+ if (attrs.type !== 'symbol' || attrs.file !== filePath) return;
1365
+ graphKeys.set(attrs.name, node);
1366
+ });
1367
+
1368
+ // Parse the CURRENT file from disk
1369
+ let currentSymbols = new Map();
1370
+ try {
1371
+ const absPath = join(this.projectRoot, filePath);
1372
+ const source = await readFile(absPath, 'utf-8');
1373
+ const parsed = parseFile(filePath, source);
1374
+ if (parsed) {
1375
+ for (const def of parsed.definitions) {
1376
+ currentSymbols.set(def.name, {
1377
+ kind: def.kind,
1378
+ signature: def.signature,
1379
+ });
1380
+ }
1381
+ }
1382
+ } catch {
1383
+ // File might be deleted — all old symbols are "removed"
1384
+ }
1385
+
1386
+ const symbolChanges = [];
1387
+
1388
+ // Detect removed symbols (in old ref, not on disk)
1389
+ for (const [name, old] of oldSymbols) {
1390
+ if (!currentSymbols.has(name)) {
1391
+ const gk = graphKeys.get(name);
1392
+ const callerCount = gk ? this._countExternalCallers(graph, gk, filePath) : 0;
1393
+ symbolChanges.push({ name, kind: old.kind, change: 'removed', signature: old.signature, callerCount });
1394
+ totalCallers += callerCount;
1395
+ }
1396
+ }
1397
+
1398
+ // Detect added and modified symbols
1399
+ for (const [name, current] of currentSymbols) {
1400
+ const old = oldSymbols.get(name);
1401
+ if (!old) {
1402
+ symbolChanges.push({ name, kind: current.kind, change: 'added', signature: current.signature, callerCount: 0 });
1403
+ } else if (old.signature !== current.signature) {
1404
+ const gk = graphKeys.get(name);
1405
+ const callerCount = gk ? this._countExternalCallers(graph, gk, filePath) : 0;
1406
+ symbolChanges.push({ name, kind: current.kind, change: 'modified', signature: current.signature, callerCount });
1407
+ totalCallers += callerCount;
1408
+ }
1409
+ }
1410
+
1411
+ if (symbolChanges.length > 0) {
1412
+ symbolChanges.sort((a, b) => b.callerCount - a.callerCount);
1413
+ results.push({ file: filePath, symbols: symbolChanges });
1414
+ }
1415
+ }
1416
+
1417
+ results.sort((a, b) => {
1418
+ const aMax = Math.max(0, ...a.symbols.map(s => s.callerCount));
1419
+ const bMax = Math.max(0, ...b.symbols.map(s => s.callerCount));
1420
+ return bMax - aMax;
1421
+ });
1422
+
1423
+ return { changed: results, totalCallers };
1424
+ }
1425
+
1426
+ /** Count unique external files that reference a symbol node. */
1427
+ _countExternalCallers(graph, symbolKey, symbolFile) {
1428
+ const files = new Set();
1429
+ graph.forEachInEdge(symbolKey, (_edge, attrs, source) => {
1430
+ if (attrs.type !== 'REFERENCES') return;
1431
+ const sourceAttrs = graph.getNodeAttributes(source);
1432
+ const sf = sourceAttrs.file || source;
1433
+ if (sf !== symbolFile) files.add(sf);
1434
+ });
1435
+ return files.size;
1436
+ }
1437
+
911
1438
  /**
912
1439
  * Force a full rebuild.
913
1440
  */
package/src/outline.js CHANGED
@@ -12,9 +12,11 @@ const MIN_COLLAPSE_LINES = 2;
12
12
  * @param {string} source - File contents
13
13
  * @param {string} filePath - File path (for language detection)
14
14
  * @param {string[]} expandSymbols - Symbol names to expand (empty = outline mode)
15
+ * @param {object} [opts]
16
+ * @param {Map<string,number>} [opts.callerCounts] - Map of symbol name → caller file count (for --annotate)
15
17
  * @returns {string} Formatted output with line numbers
16
18
  */
17
- export function buildOutline(source, filePath, expandSymbols = []) {
19
+ export function buildOutline(source, filePath, expandSymbols = [], { callerCounts } = {}) {
18
20
  const lines = source.split('\n');
19
21
  const pad = Math.max(String(lines.length).length, 4);
20
22
 
@@ -41,7 +43,7 @@ export function buildOutline(source, filePath, expandSymbols = []) {
41
43
  if (expandSymbols.length > 0) {
42
44
  return expandMode(lines, defs, filePath, expandSymbols, pad);
43
45
  }
44
- return outlineMode(lines, defs, pad);
46
+ return outlineMode(lines, defs, pad, callerCounts);
45
47
  }
46
48
 
47
49
  function rawView(lines, pad) {
@@ -82,7 +84,7 @@ function expandMode(lines, defs, filePath, expandSymbols, pad) {
82
84
  return output.join('\n').trimEnd();
83
85
  }
84
86
 
85
- function outlineMode(lines, defs, pad) {
87
+ function outlineMode(lines, defs, pad, callerCounts) {
86
88
  // Detect containers: definitions that have child definitions inside them
87
89
  const containers = new Set();
88
90
  for (const def of defs) {
@@ -96,6 +98,7 @@ function outlineMode(lines, defs, pad) {
96
98
  }
97
99
 
98
100
  // Build collapse ranges for leaf definitions with sufficient body size
101
+ // Track which definition each collapse range belongs to (for annotations)
99
102
  const collapseRanges = [];
100
103
  for (const def of defs) {
101
104
  if (containers.has(def)) continue;
@@ -109,6 +112,7 @@ function outlineMode(lines, defs, pad) {
109
112
  start: def.bodyStartLine,
110
113
  end: def.lineEnd,
111
114
  lineCount: bodyLineCount,
115
+ name: def.name,
112
116
  });
113
117
  }
114
118
 
@@ -122,10 +126,25 @@ function outlineMode(lines, defs, pad) {
122
126
  while (lineNum <= lines.length) {
123
127
  if (rangeIdx < collapseRanges.length && collapseRanges[rangeIdx].start === lineNum) {
124
128
  const range = collapseRanges[rangeIdx];
125
- // Use the indent of the first body line for natural alignment
126
129
  const bodyLine = lines[lineNum - 1];
127
130
  const indent = bodyLine ? bodyLine.match(/^\s*/)[0] : '';
128
- output.push(`${' '.repeat(pad)}│ ${indent}... (${range.lineCount} lines)`);
131
+ let marker = `${' '.repeat(pad)}│ ${indent}... (${range.lineCount} lines)`;
132
+
133
+ // Append caller annotation if available
134
+ if (callerCounts && callerCounts.has(range.name)) {
135
+ const count = callerCounts.get(range.name);
136
+ const annotation = count === 1 ? '← 1 caller' : `← ${count} callers`;
137
+ // Right-pad to align annotations
138
+ const minWidth = 60;
139
+ if (marker.length < minWidth) {
140
+ marker += ' '.repeat(minWidth - marker.length);
141
+ } else {
142
+ marker += ' ';
143
+ }
144
+ marker += annotation;
145
+ }
146
+
147
+ output.push(marker);
129
148
  lineNum = range.end + 1;
130
149
  rangeIdx++;
131
150
  continue;