@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 +124 -3
- package/package.json +1 -1
- package/src/cli.js +172 -4
- package/src/index.js +369 -3
- package/src/outline.js +158 -0
- package/src/parser.js +32 -0
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
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|