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