@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 +112 -7
- package/package.json +1 -1
- package/src/cli.js +211 -11
- package/src/index.js +530 -3
- package/src/outline.js +24 -5
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.
|
|
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]
|
|
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]
|
|
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>
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|