@mishasinitcyn/betterrank 0.2.0 → 0.2.1

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,14 @@ 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
+ # Trace the full call chain from entry point to function
32
+ betterrank trace calculate_bid --root /path/to/project
33
+
34
+ # What symbols changed and what might break?
35
+ betterrank diff --root /path/to/project
30
36
 
31
37
  # What depends on this file?
32
38
  betterrank dependents src/auth/handlers.ts --root /path/to/project
@@ -65,11 +71,14 @@ betterrank outline src/auth.py authenticate_user
65
71
  # Expand multiple symbols
66
72
  betterrank outline src/auth.py validate,process
67
73
 
74
+ # Show caller counts next to each function (requires --root)
75
+ betterrank outline src/auth.py --annotate --root ./backend
76
+
68
77
  # Resolve path relative to a root
69
78
  betterrank outline src/auth.py --root ./backend
70
79
  ```
71
80
 
72
- **Example output:**
81
+ **Example output (with `--annotate`):**
73
82
  ```
74
83
  1│ from fastapi import APIRouter, Depends
75
84
  2│ from core.auth import verify_auth
@@ -78,14 +87,14 @@ betterrank outline src/auth.py --root ./backend
78
87
  5│
79
88
  6│ @router.get("/users")
80
89
  7│ async def list_users(db = Depends(get_db)):
81
- │ ... (25 lines)
90
+ │ ... (25 lines) ← 2 callers
82
91
  33│
83
92
  34│ @router.post("/users")
84
93
  35│ async def create_user(data: UserCreate, db = Depends(get_db)):
85
- │ ... (40 lines)
94
+ │ ... (40 lines) ← 5 callers
86
95
  ```
87
96
 
88
- Typical compression: **3-5x** (a 2000-line file becomes ~400 lines of outline).
97
+ 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
98
 
90
99
  ### `map` — Repo map
91
100
 
@@ -119,7 +128,68 @@ betterrank symbols --root /path/to/project --kind function
119
128
  ### `callers` — Who calls this symbol
120
129
 
121
130
  ```bash
131
+ # File names only
122
132
  betterrank callers authenticateUser --root /path/to/project
133
+
134
+ # With call site context lines (default 2 lines around each site)
135
+ betterrank callers authenticateUser --root /path/to/project --context
136
+
137
+ # Custom context window
138
+ betterrank callers authenticateUser --root /path/to/project --context 3
139
+ ```
140
+
141
+ **Example output (with `--context`):**
142
+ ```
143
+ src/engine/pipeline.py:
144
+ 16│ from app.engine.bidding import run_auction
145
+ 17│
146
+
147
+ 153│ return await run_auction(
148
+ 154│ searched=campaigns,
149
+ 155│ publisher=config.publisher,
150
+
151
+ src/api/serve.py:
152
+ 145│ bid = await run_auction(searched, publisher=pub_id)
153
+ ```
154
+
155
+ ### `trace` — Recursive caller chain
156
+
157
+ 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.
158
+
159
+ ```bash
160
+ betterrank trace calculate_bid --root /path/to/project
161
+ betterrank trace calculate_bid --root /path/to/project --depth 5
162
+ ```
163
+
164
+ **Example output:**
165
+ ```
166
+ calculate_bid (src/engine/bidding.py:489)
167
+ ← run_auction (src/engine/bidding.py:833)
168
+ ← handle_request (src/engine/pipeline.py:153)
169
+ ← app (src/main.py:45)
170
+ ```
171
+
172
+ ### `diff` — Git-aware blast radius
173
+
174
+ 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.
175
+
176
+ ```bash
177
+ # Uncommitted changes vs HEAD
178
+ betterrank diff --root /path/to/project
179
+
180
+ # Changes since a specific commit or branch
181
+ betterrank diff --ref main --root /path/to/project
182
+ betterrank diff --ref HEAD~5 --root /path/to/project
183
+ ```
184
+
185
+ **Example output:**
186
+ ```
187
+ src/engine/bidding.py:
188
+ ~ [function] calculate_bid (3 callers)
189
+ + [function] compute_value_multi
190
+ - [function] old_quality_score (1 caller)
191
+
192
+ ⚠ 4 external callers of modified/removed symbols
123
193
  ```
124
194
 
125
195
  ### `deps` — What does this file import
@@ -185,7 +255,8 @@ const idx = new CodeIndex('/path/to/project');
185
255
 
186
256
  const map = await idx.map({ limit: 100, focusFiles: ['src/main.ts'] });
187
257
  const results = await idx.search({ query: 'auth', kind: 'function', limit: 10 });
188
- const callers = await idx.callers({ symbol: 'authenticate' });
258
+ const callers = await idx.callers({ symbol: 'authenticate', context: 2 });
259
+ const counts = await idx.getCallerCounts('src/auth.ts');
189
260
  const deps = await idx.dependencies({ file: 'src/auth.ts' });
190
261
  const dependents = await idx.dependents({ file: 'src/auth.ts' });
191
262
  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.1",
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,14 @@ 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
+ trace <symbol> [--depth N] Recursive caller chain (call tree)
22
+ diff [--ref <commit>] Git-aware blast radius (changed symbols + callers)
21
23
  deps <file> What this file imports (ranked)
22
24
  dependents <file> What imports this file (ranked)
23
25
  neighborhood <file> [--hops N] [--max-files N] Local subgraph (ranked by PageRank)
@@ -34,7 +36,7 @@ Global flags:
34
36
  `.trim();
35
37
 
36
38
  const COMMAND_HELP = {
37
- outline: `betterrank outline <file> [symbol1,symbol2,...] [--root <path>]
39
+ outline: `betterrank outline <file> [symbol1,symbol2,...] [--annotate --root <path>]
38
40
 
39
41
  View a file's structure with function/class bodies collapsed, or expand
40
42
  specific symbols to see their full source.
@@ -46,13 +48,15 @@ With symbol names (comma-separated): shows the full source of those
46
48
  specific functions/classes with line numbers.
47
49
 
48
50
  Options:
49
- --root <path> Resolve file path relative to this directory
51
+ --root <path> Resolve file path relative to this directory
52
+ --annotate Show caller counts next to each function (requires --root)
50
53
 
51
54
  Examples:
52
55
  betterrank outline src/auth.py
53
56
  betterrank outline src/auth.py authenticate_user
54
57
  betterrank outline src/auth.py validate,process
55
- betterrank outline src/handlers.ts --root ./backend`,
58
+ betterrank outline src/handlers.ts --root ./backend
59
+ betterrank outline src/auth.py --annotate --root ./backend`,
56
60
 
57
61
  map: `betterrank map [--focus file1,file2] [--root <path>]
58
62
 
@@ -116,19 +120,54 @@ Examples:
116
120
  betterrank symbols --file src/auth/handlers.ts --root ./backend
117
121
  betterrank symbols --kind class --root . --limit 20`,
118
122
 
119
- callers: `betterrank callers <symbol> [--file path] [--root <path>]
123
+ callers: `betterrank callers <symbol> [--file path] [--context [N]] [--root <path>]
120
124
 
121
125
  Find all files that reference a symbol. Ranked by file-level PageRank.
122
126
 
123
127
  Options:
124
128
  --file <path> Disambiguate when multiple symbols share a name
129
+ --context [N] Show N lines of context around each call site (default: 2)
125
130
  --count Return count only
126
131
  --offset N Skip first N results
127
132
  --limit N Max results (default: ${DEFAULT_LIMIT})
128
133
 
129
134
  Examples:
130
135
  betterrank callers authenticateUser --root ./backend
131
- betterrank callers resolve --file src/utils.ts --root .`,
136
+ betterrank callers authenticateUser --root ./backend --context
137
+ betterrank callers resolve --file src/utils.ts --root . --context 3`,
138
+
139
+ trace: `betterrank trace <symbol> [--depth N] [--file path] [--root <path>]
140
+
141
+ Recursive caller chain — walk UP the call graph from a symbol to see
142
+ the full path from entry points to your function.
143
+
144
+ At each hop, resolves which function in the caller file contains the
145
+ call site. Displays as an indented tree.
146
+
147
+ Options:
148
+ --depth N Max hops upward (default: 3)
149
+ --file <path> Disambiguate when multiple symbols share a name
150
+
151
+ Examples:
152
+ betterrank trace calculate_bid --root .
153
+ betterrank trace send_to_firehose --root . --depth 4
154
+ betterrank trace authenticate --file src/auth.ts --root .`,
155
+
156
+ diff: `betterrank diff [--ref <commit>] [--root <path>]
157
+
158
+ Git-aware blast radius — shows which symbols changed in the working tree
159
+ and how many external files call each changed symbol.
160
+
161
+ Compares current files on disk against the indexed state. Shows added,
162
+ removed, and modified symbols with their caller counts.
163
+
164
+ Options:
165
+ --ref <commit> Git ref to diff against (default: HEAD)
166
+
167
+ Examples:
168
+ betterrank diff --root .
169
+ betterrank diff --ref main --root .
170
+ betterrank diff --ref HEAD~3 --root .`,
132
171
 
133
172
  deps: `betterrank deps <file> [--root <path>]
134
173
 
@@ -252,14 +291,20 @@ async function main() {
252
291
  return; // Keep process alive (server is listening)
253
292
  }
254
293
 
255
- // Outline command — standalone, no CodeIndex needed
294
+ // Outline command — standalone by default, needs CodeIndex for --annotate
256
295
  if (command === 'outline') {
257
296
  const filePath = flags._positional[0];
258
297
  if (!filePath) {
259
- console.error('Usage: betterrank outline <file> [symbol1,symbol2]');
298
+ console.error('Usage: betterrank outline <file> [symbol1,symbol2] [--annotate --root <path>]');
260
299
  process.exit(1);
261
300
  }
262
301
  const expandSymbols = flags._positional[1] ? flags._positional[1].split(',') : [];
302
+ const annotate = flags.annotate === true;
303
+
304
+ if (annotate && !flags.root) {
305
+ console.error('outline --annotate requires --root for graph data');
306
+ process.exit(1);
307
+ }
263
308
 
264
309
  const root = flags.root ? resolve(flags.root) : process.cwd();
265
310
  const absPath = isAbsolute(filePath) ? filePath : resolve(root, filePath);
@@ -275,7 +320,14 @@ async function main() {
275
320
 
276
321
  const relPath = relative(root, absPath);
277
322
  const { buildOutline } = await import('./outline.js');
278
- const result = buildOutline(source, relPath, expandSymbols);
323
+
324
+ let callerCounts;
325
+ if (annotate) {
326
+ const idx = new CodeIndex(resolve(flags.root));
327
+ callerCounts = await idx.getCallerCounts(relPath);
328
+ }
329
+
330
+ const result = buildOutline(source, relPath, expandSymbols, { callerCounts });
279
331
  console.log(result);
280
332
  return;
281
333
  }
@@ -421,9 +473,29 @@ async function main() {
421
473
  const symbol = flags._positional[0];
422
474
  if (!symbol) { console.error('Usage: betterrank callers <symbol> [--file path]'); process.exit(1); }
423
475
  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 });
476
+ const contextLines = flags.context === true ? 2 : (flags.context ? parseInt(flags.context, 10) : 0);
477
+ const result = await idx.callers({ symbol, file: normalizeFilePath(flags.file), count: countMode, offset, limit: effectiveLimit, context: contextLines });
425
478
  if (countMode) {
426
479
  console.log(`total: ${result.total}`);
480
+ } else if (contextLines > 0) {
481
+ // Rich output with call-site context
482
+ const pad = 6;
483
+ for (const c of result) {
484
+ console.log(`${c.file}:`);
485
+ if (c.sites && c.sites.length > 0) {
486
+ for (const site of c.sites) {
487
+ for (const t of site.text) {
488
+ console.log(` ${String(t.line).padStart(pad)}│ ${t.content}`);
489
+ }
490
+ console.log('');
491
+ }
492
+ } else {
493
+ console.log(' (no call sites found in source)\n');
494
+ }
495
+ }
496
+ if (result.length === 0) {
497
+ console.log('(no callers found)');
498
+ }
427
499
  } else {
428
500
  for (const c of result) {
429
501
  console.log(c.file);
@@ -437,6 +509,52 @@ async function main() {
437
509
  break;
438
510
  }
439
511
 
512
+ case 'trace': {
513
+ const symbol = flags._positional[0];
514
+ if (!symbol) { console.error('Usage: betterrank trace <symbol> [--depth N]'); process.exit(1); }
515
+ const traceDepth = flags.depth ? parseInt(flags.depth, 10) : 3;
516
+ const tree = await idx.trace({ symbol, file: normalizeFilePath(flags.file), depth: traceDepth });
517
+ if (!tree) {
518
+ console.log(`(symbol "${symbol}" not found)`);
519
+ } else {
520
+ const printNode = (node, depth) => {
521
+ const indent = depth === 0 ? '' : ' '.repeat(depth) + '← ';
522
+ const loc = `(${node.file}:${node.line || '?'})`;
523
+ console.log(`${indent}${node.name} ${loc}`);
524
+ for (const caller of node.callers) {
525
+ printNode(caller, depth + 1);
526
+ }
527
+ };
528
+ printNode(tree, 0);
529
+ }
530
+ break;
531
+ }
532
+
533
+ case 'diff': {
534
+ const result = await idx.diff({ ref: flags.ref || 'HEAD' });
535
+ if (result.error) {
536
+ console.error(result.error);
537
+ break;
538
+ }
539
+ if (result.changed.length === 0) {
540
+ console.log('(no symbol changes detected)');
541
+ break;
542
+ }
543
+ for (const entry of result.changed) {
544
+ console.log(`${entry.file}:`);
545
+ for (const s of entry.symbols) {
546
+ const tag = s.change === 'added' ? '+' : s.change === 'removed' ? '-' : '~';
547
+ const callerNote = s.callerCount > 0 ? ` (${s.callerCount} caller${s.callerCount === 1 ? '' : 's'})` : '';
548
+ console.log(` ${tag} [${s.kind}] ${s.name}${callerNote}`);
549
+ }
550
+ console.log('');
551
+ }
552
+ if (result.totalCallers > 0) {
553
+ console.log(`⚠ ${result.totalCallers} external caller${result.totalCallers === 1 ? '' : 's'} of modified/removed symbols`);
554
+ }
555
+ break;
556
+ }
557
+
440
558
  case 'deps': {
441
559
  const file = normalizeFilePath(flags._positional[0]);
442
560
  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,318 @@ 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
+ * Recursive caller chain — walk UP the call graph from a symbol.
1001
+ * At each hop, resolves which function in the caller file contains
1002
+ * the call site by cross-referencing line numbers with definitions.
1003
+ *
1004
+ * Returns a tree: { name, file, line, callers: [...] }
1005
+ *
1006
+ * @param {object} opts
1007
+ * @param {string} opts.symbol - Starting symbol name
1008
+ * @param {string} [opts.file] - Disambiguate by file
1009
+ * @param {number} [opts.depth=3] - Max hops upward
1010
+ * @returns {object} Tree root node
1011
+ */
1012
+ async trace({ symbol, file, depth = 3 }) {
1013
+ await this._ensureReady();
1014
+ const graph = this.cache.getGraph();
1015
+ if (!graph) return null;
1016
+
1017
+ // Find the target symbol node(s)
1018
+ const targetKeys = [];
1019
+ graph.forEachNode((node, attrs) => {
1020
+ if (attrs.type !== 'symbol') return;
1021
+ if (attrs.name !== symbol) return;
1022
+ if (file && attrs.file !== file) return;
1023
+ targetKeys.push(node);
1024
+ });
1025
+
1026
+ if (targetKeys.length === 0) return null;
1027
+
1028
+ // Use the first match (highest PageRank if multiple)
1029
+ const ranked = this._getRanked();
1030
+ const scoreMap = new Map(ranked);
1031
+ targetKeys.sort((a, b) => (scoreMap.get(b) || 0) - (scoreMap.get(a) || 0));
1032
+ const rootKey = targetKeys[0];
1033
+ const rootAttrs = graph.getNodeAttributes(rootKey);
1034
+
1035
+ // Cache of file -> definitions (avoid re-parsing the same file)
1036
+ const defCache = new Map();
1037
+
1038
+ const getFileDefs = async (filePath) => {
1039
+ if (defCache.has(filePath)) return defCache.get(filePath);
1040
+ try {
1041
+ const absPath = join(this.projectRoot, filePath);
1042
+ const source = await readFile(absPath, 'utf-8');
1043
+ const parsed = parseFile(filePath, source);
1044
+ const defs = parsed ? parsed.definitions.sort((a, b) => a.lineStart - b.lineStart) : [];
1045
+ defCache.set(filePath, defs);
1046
+ return defs;
1047
+ } catch {
1048
+ defCache.set(filePath, []);
1049
+ return [];
1050
+ }
1051
+ };
1052
+
1053
+ // Find which definition in a file contains a given line
1054
+ const findContainingDef = (defs, line) => {
1055
+ for (let i = defs.length - 1; i >= 0; i--) {
1056
+ if (line >= defs[i].lineStart && line <= defs[i].lineEnd) return defs[i];
1057
+ }
1058
+ return null;
1059
+ };
1060
+
1061
+ // BFS upward through callers
1062
+ const visited = new Set(); // "file::symbol" keys to prevent cycles
1063
+
1064
+ const buildNode = async (symbolName, symbolFile, symbolLine, currentDepth) => {
1065
+ const nodeKey = `${symbolFile}::${symbolName}`;
1066
+ const node = { name: symbolName, file: symbolFile, line: symbolLine, callers: [] };
1067
+
1068
+ if (currentDepth >= depth) return node;
1069
+ if (visited.has(nodeKey)) return node;
1070
+ visited.add(nodeKey);
1071
+
1072
+ // Find this symbol in the graph
1073
+ const symKeys = [];
1074
+ graph.forEachNode((gNode, attrs) => {
1075
+ if (attrs.type !== 'symbol') return;
1076
+ if (attrs.name !== symbolName) return;
1077
+ if (attrs.file !== symbolFile) return;
1078
+ symKeys.push(gNode);
1079
+ });
1080
+
1081
+ // Collect caller files
1082
+ const callerFiles = new Set();
1083
+ for (const sk of symKeys) {
1084
+ graph.forEachInEdge(sk, (_edge, attrs, source) => {
1085
+ if (attrs.type !== 'REFERENCES') return;
1086
+ const sourceAttrs = graph.getNodeAttributes(source);
1087
+ const sf = sourceAttrs.file || source;
1088
+ if (sf !== symbolFile) callerFiles.add(sf);
1089
+ });
1090
+ }
1091
+
1092
+ // For each caller file, find which function contains the call
1093
+ const callPattern = new RegExp(
1094
+ `(?<![a-zA-Z0-9_])${symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`
1095
+ );
1096
+
1097
+ for (const callerFile of callerFiles) {
1098
+ try {
1099
+ const absPath = join(this.projectRoot, callerFile);
1100
+ const source = await readFile(absPath, 'utf-8');
1101
+ const lines = source.split('\n');
1102
+ const defs = await getFileDefs(callerFile);
1103
+
1104
+ // Find the first call site line
1105
+ let callLine = null;
1106
+ for (let i = 0; i < lines.length; i++) {
1107
+ if (callPattern.test(lines[i])) { callLine = i + 1; break; }
1108
+ }
1109
+
1110
+ // Resolve to containing function
1111
+ const containingDef = callLine ? findContainingDef(defs, callLine) : null;
1112
+
1113
+ if (containingDef) {
1114
+ const callerNode = await buildNode(containingDef.name, callerFile, containingDef.lineStart, currentDepth + 1);
1115
+ node.callers.push(callerNode);
1116
+ } else {
1117
+ // Top-level call (not inside any function) — show as file-level
1118
+ node.callers.push({ name: `<module>`, file: callerFile, line: callLine, callers: [] });
1119
+ }
1120
+ } catch {
1121
+ // Skip unreadable files
1122
+ }
1123
+ }
1124
+
1125
+ // Sort callers by file for deterministic output
1126
+ node.callers.sort((a, b) => a.file.localeCompare(b.file));
1127
+ return node;
1128
+ };
1129
+
1130
+ return buildNode(rootAttrs.name, rootAttrs.file, rootAttrs.lineStart, 0);
1131
+ }
1132
+
1133
+ /**
1134
+ * Git-aware blast radius — what symbols changed and who calls them.
1135
+ *
1136
+ * Compares the working tree (or a git ref) against the index to find
1137
+ * added, removed, and modified symbols, then looks up their callers.
1138
+ *
1139
+ * @param {object} [opts]
1140
+ * @param {string} [opts.ref] - Git ref to diff against (default: HEAD)
1141
+ * @returns {{ changed: Array<{file, symbols: Array<{name, kind, change, callerCount}>}>, totalCallers: number }}
1142
+ */
1143
+ async diff({ ref = 'HEAD' } = {}) {
1144
+ await this._ensureReady();
1145
+ const graph = this.cache.getGraph();
1146
+ if (!graph) return { changed: [], totalCallers: 0 };
1147
+
1148
+ // Get changed files from git
1149
+ const { execSync } = await import('child_process');
1150
+ let changedFiles;
1151
+ try {
1152
+ const output = execSync(`git diff --name-only ${ref}`, {
1153
+ cwd: this.projectRoot,
1154
+ encoding: 'utf-8',
1155
+ timeout: 10000,
1156
+ }).trim();
1157
+ if (!output) return { changed: [], totalCallers: 0 };
1158
+ changedFiles = output.split('\n').filter(Boolean);
1159
+ } catch {
1160
+ return { changed: [], totalCallers: 0, error: 'git diff failed — is this a git repo?' };
1161
+ }
1162
+
1163
+ // Also include untracked new files
1164
+ try {
1165
+ const untracked = execSync('git ls-files --others --exclude-standard', {
1166
+ cwd: this.projectRoot,
1167
+ encoding: 'utf-8',
1168
+ timeout: 10000,
1169
+ }).trim();
1170
+ if (untracked) {
1171
+ for (const f of untracked.split('\n').filter(Boolean)) {
1172
+ if (!changedFiles.includes(f)) changedFiles.push(f);
1173
+ }
1174
+ }
1175
+ } catch { /* ignore */ }
1176
+
1177
+ const results = [];
1178
+ let totalCallers = 0;
1179
+
1180
+ for (const filePath of changedFiles) {
1181
+ // Get OLD symbols from git ref
1182
+ const oldSymbols = new Map();
1183
+ try {
1184
+ const oldSource = execSync(`git show ${ref}:${filePath}`, {
1185
+ cwd: this.projectRoot,
1186
+ encoding: 'utf-8',
1187
+ stdio: ['pipe', 'pipe', 'pipe'],
1188
+ timeout: 10000,
1189
+ });
1190
+ const oldParsed = parseFile(filePath, oldSource);
1191
+ if (oldParsed) {
1192
+ for (const def of oldParsed.definitions) {
1193
+ oldSymbols.set(def.name, { kind: def.kind, signature: def.signature });
1194
+ }
1195
+ }
1196
+ } catch {
1197
+ // File is new (doesn't exist in ref) — all symbols are "added"
1198
+ }
1199
+
1200
+ // Get graph keys for caller lookups on current symbols
1201
+ const graphKeys = new Map();
1202
+ graph.forEachNode((node, attrs) => {
1203
+ if (attrs.type !== 'symbol' || attrs.file !== filePath) return;
1204
+ graphKeys.set(attrs.name, node);
1205
+ });
1206
+
1207
+ // Parse the CURRENT file from disk
1208
+ let currentSymbols = new Map();
1209
+ try {
1210
+ const absPath = join(this.projectRoot, filePath);
1211
+ const source = await readFile(absPath, 'utf-8');
1212
+ const parsed = parseFile(filePath, source);
1213
+ if (parsed) {
1214
+ for (const def of parsed.definitions) {
1215
+ currentSymbols.set(def.name, {
1216
+ kind: def.kind,
1217
+ signature: def.signature,
1218
+ });
1219
+ }
1220
+ }
1221
+ } catch {
1222
+ // File might be deleted — all old symbols are "removed"
1223
+ }
1224
+
1225
+ const symbolChanges = [];
1226
+
1227
+ // Detect removed symbols (in old ref, not on disk)
1228
+ for (const [name, old] of oldSymbols) {
1229
+ if (!currentSymbols.has(name)) {
1230
+ const gk = graphKeys.get(name);
1231
+ const callerCount = gk ? this._countExternalCallers(graph, gk, filePath) : 0;
1232
+ symbolChanges.push({ name, kind: old.kind, change: 'removed', signature: old.signature, callerCount });
1233
+ totalCallers += callerCount;
1234
+ }
1235
+ }
1236
+
1237
+ // Detect added and modified symbols
1238
+ for (const [name, current] of currentSymbols) {
1239
+ const old = oldSymbols.get(name);
1240
+ if (!old) {
1241
+ symbolChanges.push({ name, kind: current.kind, change: 'added', signature: current.signature, callerCount: 0 });
1242
+ } else if (old.signature !== current.signature) {
1243
+ const gk = graphKeys.get(name);
1244
+ const callerCount = gk ? this._countExternalCallers(graph, gk, filePath) : 0;
1245
+ symbolChanges.push({ name, kind: current.kind, change: 'modified', signature: current.signature, callerCount });
1246
+ totalCallers += callerCount;
1247
+ }
1248
+ }
1249
+
1250
+ if (symbolChanges.length > 0) {
1251
+ symbolChanges.sort((a, b) => b.callerCount - a.callerCount);
1252
+ results.push({ file: filePath, symbols: symbolChanges });
1253
+ }
1254
+ }
1255
+
1256
+ results.sort((a, b) => {
1257
+ const aMax = Math.max(0, ...a.symbols.map(s => s.callerCount));
1258
+ const bMax = Math.max(0, ...b.symbols.map(s => s.callerCount));
1259
+ return bMax - aMax;
1260
+ });
1261
+
1262
+ return { changed: results, totalCallers };
1263
+ }
1264
+
1265
+ /** Count unique external files that reference a symbol node. */
1266
+ _countExternalCallers(graph, symbolKey, symbolFile) {
1267
+ const files = new Set();
1268
+ graph.forEachInEdge(symbolKey, (_edge, attrs, source) => {
1269
+ if (attrs.type !== 'REFERENCES') return;
1270
+ const sourceAttrs = graph.getNodeAttributes(source);
1271
+ const sf = sourceAttrs.file || source;
1272
+ if (sf !== symbolFile) files.add(sf);
1273
+ });
1274
+ return files.size;
1275
+ }
1276
+
911
1277
  /**
912
1278
  * Force a full rebuild.
913
1279
  */
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;