@mishasinitcyn/betterrank 0.1.9 → 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
@@ -16,11 +16,23 @@ npm install -g @mishasinitcyn/betterrank
16
16
  # Get a ranked overview of any project
17
17
  betterrank map --root /path/to/project
18
18
 
19
+ # View a file's skeleton without reading the whole thing
20
+ betterrank outline src/auth.py
21
+
22
+ # Expand a specific function to see its full source
23
+ betterrank outline src/auth.py authenticate_user
24
+
19
25
  # Search for symbols by name or parameter
20
26
  betterrank search auth --root /path/to/project
21
27
 
22
- # Who calls this function?
23
- 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
24
36
 
25
37
  # What depends on this file?
26
38
  betterrank dependents src/auth/handlers.ts --root /path/to/project
@@ -45,6 +57,45 @@ JavaScript, TypeScript, Python, Rust, Go, Java, Ruby, C, C++, C#, PHP
45
57
 
46
58
  ## Commands
47
59
 
60
+ ### `outline` — File skeleton with collapsed bodies
61
+
62
+ View a file's structure without reading the entire thing. Function and class bodies are collapsed to `... (N lines)`. Expand specific symbols by name. **No `--root` required** — works on any file standalone.
63
+
64
+ ```bash
65
+ # Skeleton view: imports, signatures, constants — bodies collapsed
66
+ betterrank outline src/auth.py
67
+
68
+ # Expand a specific function to see its full source
69
+ betterrank outline src/auth.py authenticate_user
70
+
71
+ # Expand multiple symbols
72
+ betterrank outline src/auth.py validate,process
73
+
74
+ # Show caller counts next to each function (requires --root)
75
+ betterrank outline src/auth.py --annotate --root ./backend
76
+
77
+ # Resolve path relative to a root
78
+ betterrank outline src/auth.py --root ./backend
79
+ ```
80
+
81
+ **Example output (with `--annotate`):**
82
+ ```
83
+ 1│ from fastapi import APIRouter, Depends
84
+ 2│ from core.auth import verify_auth
85
+ 3│
86
+ 4│ router = APIRouter(prefix="/api")
87
+ 5│
88
+ 6│ @router.get("/users")
89
+ 7│ async def list_users(db = Depends(get_db)):
90
+ │ ... (25 lines) ← 2 callers
91
+ 33│
92
+ 34│ @router.post("/users")
93
+ 35│ async def create_user(data: UserCreate, db = Depends(get_db)):
94
+ │ ... (40 lines) ← 5 callers
95
+ ```
96
+
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.
98
+
48
99
  ### `map` — Repo map
49
100
 
50
101
  Summary of the most structurally important definitions, ranked by PageRank. Default limit is 50 symbols.
@@ -77,7 +128,68 @@ betterrank symbols --root /path/to/project --kind function
77
128
  ### `callers` — Who calls this symbol
78
129
 
79
130
  ```bash
131
+ # File names only
80
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
81
193
  ```
82
194
 
83
195
  ### `deps` — What does this file import
@@ -99,6 +211,14 @@ betterrank neighborhood src/auth.ts --root /path/to/project --count
99
211
  betterrank neighborhood src/auth.ts --root /path/to/project --hops 2 --limit 10
100
212
  ```
101
213
 
214
+ ### `orphans` — Find disconnected files/symbols
215
+
216
+ ```bash
217
+ betterrank orphans --root /path/to/project # orphan files
218
+ betterrank orphans --level symbol --root /path/to/project # orphan symbols
219
+ betterrank orphans --level symbol --kind function --root /path/to/project
220
+ ```
221
+
102
222
  ### `structure` — File tree with symbol counts
103
223
 
104
224
  ```bash
@@ -135,7 +255,8 @@ const idx = new CodeIndex('/path/to/project');
135
255
 
136
256
  const map = await idx.map({ limit: 100, focusFiles: ['src/main.ts'] });
137
257
  const results = await idx.search({ query: 'auth', kind: 'function', limit: 10 });
138
- 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');
139
260
  const deps = await idx.dependencies({ file: 'src/auth.ts' });
140
261
  const dependents = await idx.dependents({ file: 'src/auth.ts' });
141
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.1.9",
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { CodeIndex } from './index.js';
4
4
  import { resolve, relative, isAbsolute } from 'path';
5
+ import { readFile } from 'fs/promises';
5
6
 
6
7
  const DEFAULT_LIMIT = 50;
7
8
  const DEFAULT_DEPTH = 3;
@@ -11,11 +12,14 @@ betterrank <command> [options]
11
12
 
12
13
  Commands:
13
14
  ui [--port N] Launch web UI (default port: 3333)
15
+ outline <file> [symbol1,symbol2] [--annotate] File skeleton (--annotate for caller counts)
14
16
  map [--focus file1,file2] Repo map (ranked by PageRank)
15
17
  search <query> [--kind type] Substring search on symbol names + signatures (ranked by PageRank)
16
18
  structure [--depth N] File tree with symbol counts (default depth: ${DEFAULT_DEPTH})
17
19
  symbols [--file path] [--kind type] List definitions (ranked by PageRank)
18
- 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)
19
23
  deps <file> What this file imports (ranked)
20
24
  dependents <file> What imports this file (ranked)
21
25
  neighborhood <file> [--hops N] [--max-files N] Local subgraph (ranked by PageRank)
@@ -32,6 +36,28 @@ Global flags:
32
36
  `.trim();
33
37
 
34
38
  const COMMAND_HELP = {
39
+ outline: `betterrank outline <file> [symbol1,symbol2,...] [--annotate --root <path>]
40
+
41
+ View a file's structure with function/class bodies collapsed, or expand
42
+ specific symbols to see their full source.
43
+
44
+ Without symbol names: shows the file skeleton — imports, constants, and
45
+ function/class signatures with bodies replaced by "... (N lines)".
46
+
47
+ With symbol names (comma-separated): shows the full source of those
48
+ specific functions/classes with line numbers.
49
+
50
+ Options:
51
+ --root <path> Resolve file path relative to this directory
52
+ --annotate Show caller counts next to each function (requires --root)
53
+
54
+ Examples:
55
+ betterrank outline src/auth.py
56
+ betterrank outline src/auth.py authenticate_user
57
+ betterrank outline src/auth.py validate,process
58
+ betterrank outline src/handlers.ts --root ./backend
59
+ betterrank outline src/auth.py --annotate --root ./backend`,
60
+
35
61
  map: `betterrank map [--focus file1,file2] [--root <path>]
36
62
 
37
63
  Aider-style repo map: the most structurally important definitions ranked by PageRank.
@@ -94,19 +120,54 @@ Examples:
94
120
  betterrank symbols --file src/auth/handlers.ts --root ./backend
95
121
  betterrank symbols --kind class --root . --limit 20`,
96
122
 
97
- callers: `betterrank callers <symbol> [--file path] [--root <path>]
123
+ callers: `betterrank callers <symbol> [--file path] [--context [N]] [--root <path>]
98
124
 
99
125
  Find all files that reference a symbol. Ranked by file-level PageRank.
100
126
 
101
127
  Options:
102
128
  --file <path> Disambiguate when multiple symbols share a name
129
+ --context [N] Show N lines of context around each call site (default: 2)
103
130
  --count Return count only
104
131
  --offset N Skip first N results
105
132
  --limit N Max results (default: ${DEFAULT_LIMIT})
106
133
 
107
134
  Examples:
108
135
  betterrank callers authenticateUser --root ./backend
109
- 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 .`,
110
171
 
111
172
  deps: `betterrank deps <file> [--root <path>]
112
173
 
@@ -230,6 +291,47 @@ async function main() {
230
291
  return; // Keep process alive (server is listening)
231
292
  }
232
293
 
294
+ // Outline command — standalone by default, needs CodeIndex for --annotate
295
+ if (command === 'outline') {
296
+ const filePath = flags._positional[0];
297
+ if (!filePath) {
298
+ console.error('Usage: betterrank outline <file> [symbol1,symbol2] [--annotate --root <path>]');
299
+ process.exit(1);
300
+ }
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
+ }
308
+
309
+ const root = flags.root ? resolve(flags.root) : process.cwd();
310
+ const absPath = isAbsolute(filePath) ? filePath : resolve(root, filePath);
311
+
312
+ let source;
313
+ try {
314
+ source = await readFile(absPath, 'utf-8');
315
+ } catch (err) {
316
+ console.error(`Cannot read file: ${absPath}`);
317
+ console.error(err.message);
318
+ process.exit(1);
319
+ }
320
+
321
+ const relPath = relative(root, absPath);
322
+ const { buildOutline } = await import('./outline.js');
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 });
331
+ console.log(result);
332
+ return;
333
+ }
334
+
233
335
  const projectRoot = resolve(flags.root || process.cwd());
234
336
  if (!flags.root) {
235
337
  process.stderr.write(`⚠ No --root specified, using cwd: ${projectRoot}\n`);
@@ -371,9 +473,29 @@ async function main() {
371
473
  const symbol = flags._positional[0];
372
474
  if (!symbol) { console.error('Usage: betterrank callers <symbol> [--file path]'); process.exit(1); }
373
475
  const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
374
- 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 });
375
478
  if (countMode) {
376
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
+ }
377
499
  } else {
378
500
  for (const c of result) {
379
501
  console.log(c.file);
@@ -387,6 +509,52 @@ async function main() {
387
509
  break;
388
510
  }
389
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
+
390
558
  case 'deps': {
391
559
  const file = normalizeFilePath(flags._positional[0]);
392
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 ADDED
@@ -0,0 +1,158 @@
1
+ import { parseFile, SUPPORTED_EXTENSIONS } from './parser.js';
2
+ import { extname } from 'path';
3
+
4
+ const MIN_COLLAPSE_LINES = 2;
5
+
6
+ /**
7
+ * Build an outline view of a source file.
8
+ *
9
+ * Without expandSymbols: returns a skeleton with function/class bodies collapsed.
10
+ * With expandSymbols: returns the full source of the specified symbols.
11
+ *
12
+ * @param {string} source - File contents
13
+ * @param {string} filePath - File path (for language detection)
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)
17
+ * @returns {string} Formatted output with line numbers
18
+ */
19
+ export function buildOutline(source, filePath, expandSymbols = [], { callerCounts } = {}) {
20
+ const lines = source.split('\n');
21
+ const pad = Math.max(String(lines.length).length, 4);
22
+
23
+ const ext = extname(filePath);
24
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
25
+ return rawView(lines, pad);
26
+ }
27
+
28
+ const parsed = parseFile(filePath, source);
29
+ if (!parsed || parsed.definitions.length === 0) {
30
+ return rawView(lines, pad);
31
+ }
32
+
33
+ // Deduplicate definitions by lineStart (decorated_definition can cause dupes)
34
+ const seenLines = new Set();
35
+ const defs = parsed.definitions
36
+ .filter(d => {
37
+ if (seenLines.has(d.lineStart)) return false;
38
+ seenLines.add(d.lineStart);
39
+ return true;
40
+ })
41
+ .sort((a, b) => a.lineStart - b.lineStart);
42
+
43
+ if (expandSymbols.length > 0) {
44
+ return expandMode(lines, defs, filePath, expandSymbols, pad);
45
+ }
46
+ return outlineMode(lines, defs, pad, callerCounts);
47
+ }
48
+
49
+ function rawView(lines, pad) {
50
+ return lines.map((l, i) => `${String(i + 1).padStart(pad)}│ ${l}`).join('\n');
51
+ }
52
+
53
+ function expandMode(lines, defs, filePath, expandSymbols, pad) {
54
+ const output = [];
55
+
56
+ for (const symName of expandSymbols) {
57
+ const matches = defs.filter(d => d.name === symName);
58
+
59
+ if (matches.length === 0) {
60
+ output.push(`Symbol "${symName}" not found in ${filePath}`);
61
+ const similar = [...new Set(
62
+ defs.filter(d => d.name.toLowerCase().includes(symName.toLowerCase()))
63
+ .map(d => d.name)
64
+ )].slice(0, 5);
65
+ if (similar.length > 0) {
66
+ output.push(`Did you mean: ${similar.join(', ')}`);
67
+ } else {
68
+ output.push(`Available: ${defs.map(d => d.name).join(', ')}`);
69
+ }
70
+ continue;
71
+ }
72
+
73
+ for (const def of matches) {
74
+ if (matches.length > 1 || expandSymbols.length > 1) {
75
+ output.push(`── ${def.name} (${filePath}:${def.lineStart}-${def.lineEnd}) ──`);
76
+ }
77
+ for (let i = def.lineStart; i <= def.lineEnd; i++) {
78
+ output.push(`${String(i).padStart(pad)}│ ${lines[i - 1]}`);
79
+ }
80
+ if (matches.length > 1) output.push('');
81
+ }
82
+ }
83
+
84
+ return output.join('\n').trimEnd();
85
+ }
86
+
87
+ function outlineMode(lines, defs, pad, callerCounts) {
88
+ // Detect containers: definitions that have child definitions inside them
89
+ const containers = new Set();
90
+ for (const def of defs) {
91
+ for (const other of defs) {
92
+ if (other === def) continue;
93
+ if (other.lineStart > def.lineStart && other.lineEnd <= def.lineEnd) {
94
+ containers.add(def);
95
+ break;
96
+ }
97
+ }
98
+ }
99
+
100
+ // Build collapse ranges for leaf definitions with sufficient body size
101
+ // Track which definition each collapse range belongs to (for annotations)
102
+ const collapseRanges = [];
103
+ for (const def of defs) {
104
+ if (containers.has(def)) continue;
105
+ if (!def.bodyStartLine) continue;
106
+ if (def.bodyStartLine > def.lineEnd) continue;
107
+
108
+ const bodyLineCount = def.lineEnd - def.bodyStartLine + 1;
109
+ if (bodyLineCount < MIN_COLLAPSE_LINES) continue;
110
+
111
+ collapseRanges.push({
112
+ start: def.bodyStartLine,
113
+ end: def.lineEnd,
114
+ lineCount: bodyLineCount,
115
+ name: def.name,
116
+ });
117
+ }
118
+
119
+ collapseRanges.sort((a, b) => a.start - b.start);
120
+
121
+ // Walk lines, skipping collapsed ranges
122
+ const output = [];
123
+ let lineNum = 1;
124
+ let rangeIdx = 0;
125
+
126
+ while (lineNum <= lines.length) {
127
+ if (rangeIdx < collapseRanges.length && collapseRanges[rangeIdx].start === lineNum) {
128
+ const range = collapseRanges[rangeIdx];
129
+ const bodyLine = lines[lineNum - 1];
130
+ const indent = bodyLine ? bodyLine.match(/^\s*/)[0] : '';
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);
148
+ lineNum = range.end + 1;
149
+ rangeIdx++;
150
+ continue;
151
+ }
152
+
153
+ output.push(`${String(lineNum).padStart(pad)}│ ${lines[lineNum - 1]}`);
154
+ lineNum++;
155
+ }
156
+
157
+ return output.join('\n');
158
+ }
package/src/parser.js CHANGED
@@ -246,6 +246,26 @@ const KIND_MAP = {
246
246
  decorated_definition: 'function',
247
247
  };
248
248
 
249
+ /**
250
+ * Find the body/block node of a definition, drilling into wrappers like
251
+ * lexical_declaration → variable_declarator → arrow_function → body.
252
+ */
253
+ function findBodyNode(node) {
254
+ let body = node.childForFieldName('body');
255
+ if (body) return body;
256
+
257
+ for (let i = 0; i < node.namedChildCount; i++) {
258
+ const child = node.namedChild(i);
259
+ body = child.childForFieldName('body');
260
+ if (body) return body;
261
+ for (let j = 0; j < child.namedChildCount; j++) {
262
+ body = child.namedChild(j).childForFieldName('body');
263
+ if (body) return body;
264
+ }
265
+ }
266
+ return null;
267
+ }
268
+
249
269
  function nodeKind(nodeType) {
250
270
  return KIND_MAP[nodeType] || 'other';
251
271
  }
@@ -309,6 +329,17 @@ function parseFile(filePath, source) {
309
329
  if (!nameCapture) continue;
310
330
  const defNode = defCapture || nameCapture;
311
331
 
332
+ // Compute where body content starts (for outline collapsing)
333
+ const bodyNode = findBodyNode(defNode.node);
334
+ let bodyStartLine = null;
335
+ if (bodyNode) {
336
+ const bodyRow = bodyNode.startPosition.row; // 0-indexed
337
+ const defRow = defNode.node.startPosition.row; // 0-indexed
338
+ // If body opens on same line as def (JS: `function foo() {`),
339
+ // content starts on next line. Otherwise body IS the content.
340
+ bodyStartLine = bodyRow === defRow ? bodyRow + 2 : bodyRow + 1; // 1-indexed
341
+ }
342
+
312
343
  definitions.push({
313
344
  name: nameCapture.node.text,
314
345
  kind: nodeKind(defNode.node.type),
@@ -316,6 +347,7 @@ function parseFile(filePath, source) {
316
347
  lineStart: defNode.node.startPosition.row + 1,
317
348
  lineEnd: defNode.node.endPosition.row + 1,
318
349
  signature: extractSignature(defNode.node, langName),
350
+ bodyStartLine,
319
351
  });
320
352
  }
321
353
  } catch (e) {