@mishasinitcyn/betterrank 0.2.1 → 0.2.3
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 +59 -0
- package/package.json +1 -1
- package/src/cli.js +131 -0
- package/src/index.js +217 -0
package/README.md
CHANGED
|
@@ -28,6 +28,9 @@ betterrank search auth --root /path/to/project
|
|
|
28
28
|
# Who calls this function? (with call site context)
|
|
29
29
|
betterrank callers authenticateUser --root /path/to/project --context
|
|
30
30
|
|
|
31
|
+
# Everything about a function: source, types, deps, callers
|
|
32
|
+
betterrank context calculate_bid --root /path/to/project
|
|
33
|
+
|
|
31
34
|
# Trace the full call chain from entry point to function
|
|
32
35
|
betterrank trace calculate_bid --root /path/to/project
|
|
33
36
|
|
|
@@ -152,6 +155,62 @@ src/api/serve.py:
|
|
|
152
155
|
145│ bid = await run_auction(searched, publisher=pub_id)
|
|
153
156
|
```
|
|
154
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
|
+
### `history` — Git history of a specific function
|
|
190
|
+
|
|
191
|
+
Shows only commits that touched a function's lines. Uses tree-sitter line ranges for accuracy (better than git's heuristic `:funcname:` detection). Add `--patch` to include function-scoped diffs.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
# Commit list (compact)
|
|
195
|
+
betterrank history calculate_bid --root /path/to/project
|
|
196
|
+
|
|
197
|
+
# With function-scoped diffs
|
|
198
|
+
betterrank history calculate_bid --root /path/to/project --patch --limit 3
|
|
199
|
+
|
|
200
|
+
# Paginate through older commits
|
|
201
|
+
betterrank history calculate_bid --root /path/to/project --offset 5 --limit 5
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Example output:**
|
|
205
|
+
```
|
|
206
|
+
calculate_bid (src/engine/bidding.py:489-718)
|
|
207
|
+
|
|
208
|
+
082b9d5 2026-02-24 fix: restore GSP auction pricing
|
|
209
|
+
c75f5ff 2026-02-14 fix: resolve lint errors from main merge
|
|
210
|
+
623429c 2026-02-13 hot fix
|
|
211
|
+
5d236d3 2026-02-06 feat: wire ad_position to ValuePredictor
|
|
212
|
+
```
|
|
213
|
+
|
|
155
214
|
### `trace` — Recursive caller chain
|
|
156
215
|
|
|
157
216
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mishasinitcyn/betterrank",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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
|
@@ -18,6 +18,8 @@ Commands:
|
|
|
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
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
|
+
history <symbol> [--file path] Git history of a specific function
|
|
21
23
|
trace <symbol> [--depth N] Recursive caller chain (call tree)
|
|
22
24
|
diff [--ref <commit>] Git-aware blast radius (changed symbols + callers)
|
|
23
25
|
deps <file> What this file imports (ranked)
|
|
@@ -136,6 +138,41 @@ Examples:
|
|
|
136
138
|
betterrank callers authenticateUser --root ./backend --context
|
|
137
139
|
betterrank callers resolve --file src/utils.ts --root . --context 3`,
|
|
138
140
|
|
|
141
|
+
context: `betterrank context <symbol> [--file path] [--root <path>]
|
|
142
|
+
|
|
143
|
+
Everything you need to understand a function in one shot.
|
|
144
|
+
|
|
145
|
+
Shows: the function's source, signatures of all functions/types it references,
|
|
146
|
+
expanded type definitions from the signature, and a callers summary.
|
|
147
|
+
|
|
148
|
+
Eliminates the multi-command chase of: outline → expand → search types → callers.
|
|
149
|
+
|
|
150
|
+
Options:
|
|
151
|
+
--file <path> Disambiguate when multiple symbols share a name
|
|
152
|
+
--no-source Skip the function source (show only deps/types/callers)
|
|
153
|
+
|
|
154
|
+
Examples:
|
|
155
|
+
betterrank context calculate_bid --root .
|
|
156
|
+
betterrank context Router --file src/llm.py --root .`,
|
|
157
|
+
|
|
158
|
+
history: `betterrank history <symbol> [--file path] [--patch] [--limit N] [--root <path>]
|
|
159
|
+
|
|
160
|
+
Git history of a specific function. Uses the tree-sitter line range to
|
|
161
|
+
show only commits that touched that function's lines.
|
|
162
|
+
|
|
163
|
+
More accurate than git log -L :funcname: because betterrank knows the
|
|
164
|
+
exact line range from tree-sitter, not git's heuristic function detection.
|
|
165
|
+
|
|
166
|
+
Options:
|
|
167
|
+
--file <path> Disambiguate when multiple symbols share a name
|
|
168
|
+
--patch, -p Include function-scoped diffs (not just commit list)
|
|
169
|
+
--limit N Max commits to show (default: 20)
|
|
170
|
+
|
|
171
|
+
Examples:
|
|
172
|
+
betterrank history calculate_bid --root .
|
|
173
|
+
betterrank history calculate_bid --root . --patch --limit 3
|
|
174
|
+
betterrank history Router --file src/llm.py --root .`,
|
|
175
|
+
|
|
139
176
|
trace: `betterrank trace <symbol> [--depth N] [--file path] [--root <path>]
|
|
140
177
|
|
|
141
178
|
Recursive caller chain — walk UP the call graph from a symbol to see
|
|
@@ -509,6 +546,100 @@ async function main() {
|
|
|
509
546
|
break;
|
|
510
547
|
}
|
|
511
548
|
|
|
549
|
+
case 'context': {
|
|
550
|
+
const symbol = flags._positional[0];
|
|
551
|
+
if (!symbol) { console.error('Usage: betterrank context <symbol> [--file path]'); process.exit(1); }
|
|
552
|
+
const noSource = flags['no-source'] === true;
|
|
553
|
+
const result = await idx.context({ symbol, file: normalizeFilePath(flags.file) });
|
|
554
|
+
if (!result) {
|
|
555
|
+
console.log(`(symbol "${symbol}" not found)`);
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const def = result.definition;
|
|
560
|
+
const pad = Math.max(String(def.lineEnd).length, 4);
|
|
561
|
+
|
|
562
|
+
// Header
|
|
563
|
+
console.log(`── ${def.name} (${def.file}:${def.lineStart}-${def.lineEnd}) ──`);
|
|
564
|
+
console.log('');
|
|
565
|
+
|
|
566
|
+
// Source (or just the signature in --no-source mode)
|
|
567
|
+
if (!noSource) {
|
|
568
|
+
for (let i = 0; i < def.source.length; i++) {
|
|
569
|
+
console.log(` ${String(def.lineStart + i).padStart(pad)}│ ${def.source[i]}`);
|
|
570
|
+
}
|
|
571
|
+
console.log('');
|
|
572
|
+
} else if (def.signature) {
|
|
573
|
+
console.log(` ${def.signature}`);
|
|
574
|
+
console.log('');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Type references from signature
|
|
578
|
+
if (result.typeRefs.length > 0) {
|
|
579
|
+
console.log('Types:');
|
|
580
|
+
for (const t of result.typeRefs) {
|
|
581
|
+
console.log(` ${t.name} (${t.file}:${t.lineStart})`);
|
|
582
|
+
if (t.fields) {
|
|
583
|
+
for (const line of t.fields) {
|
|
584
|
+
console.log(` ${line}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
console.log('');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Used symbols (functions/types referenced in the body)
|
|
592
|
+
if (result.usedSymbols.length > 0) {
|
|
593
|
+
console.log('References:');
|
|
594
|
+
for (const s of result.usedSymbols) {
|
|
595
|
+
const loc = s.file === def.file ? `line ${s.lineStart}` : `${s.file}:${s.lineStart}`;
|
|
596
|
+
console.log(` [${s.kind}] ${s.signature || s.name} (${loc})`);
|
|
597
|
+
}
|
|
598
|
+
console.log('');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Callers
|
|
602
|
+
if (result.callers.length > 0) {
|
|
603
|
+
console.log(`Callers (${result.callers.length} file${result.callers.length === 1 ? '' : 's'}):`);
|
|
604
|
+
for (const f of result.callers) {
|
|
605
|
+
console.log(` ${f}`);
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
console.log('Callers: (none detected)');
|
|
609
|
+
}
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
case 'history': {
|
|
614
|
+
const symbol = flags._positional[0];
|
|
615
|
+
if (!symbol) { console.error('Usage: betterrank history <symbol> [--file path] [--patch]'); process.exit(1); }
|
|
616
|
+
const histLimit = flags.limit ? parseInt(flags.limit, 10) : 20;
|
|
617
|
+
const histOffset = flags.offset ? parseInt(flags.offset, 10) : 0;
|
|
618
|
+
const showPatch = flags.patch === true || flags.p === true;
|
|
619
|
+
const result = await idx.history({ symbol, file: normalizeFilePath(flags.file), offset: histOffset, limit: histLimit, patch: showPatch });
|
|
620
|
+
if (!result) {
|
|
621
|
+
console.log(`(symbol "${symbol}" not found)`);
|
|
622
|
+
} else if (result.error) {
|
|
623
|
+
console.error(result.error);
|
|
624
|
+
} else if (result.raw) {
|
|
625
|
+
// --patch mode: print git's full output
|
|
626
|
+
const def = result.definition;
|
|
627
|
+
console.log(`${def.name} (${def.file}:${def.lineStart}-${def.lineEnd})\n`);
|
|
628
|
+
console.log(result.raw);
|
|
629
|
+
} else {
|
|
630
|
+
const def = result.definition;
|
|
631
|
+
console.log(`${def.name} (${def.file}:${def.lineStart}-${def.lineEnd})\n`);
|
|
632
|
+
if (result.commits.length === 0) {
|
|
633
|
+
console.log('(no commits found)');
|
|
634
|
+
} else {
|
|
635
|
+
for (const line of result.commits) {
|
|
636
|
+
console.log(` ${line}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
|
|
512
643
|
case 'trace': {
|
|
513
644
|
const symbol = flags._positional[0];
|
|
514
645
|
if (!symbol) { console.error('Usage: betterrank trace <symbol> [--depth N]'); process.exit(1); }
|
package/src/index.js
CHANGED
|
@@ -996,6 +996,223 @@ class CodeIndex {
|
|
|
996
996
|
return counts;
|
|
997
997
|
}
|
|
998
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
|
+
* Git history of a specific function, using its tree-sitter line range.
|
|
1162
|
+
*
|
|
1163
|
+
* @param {object} opts
|
|
1164
|
+
* @param {string} opts.symbol - Symbol name
|
|
1165
|
+
* @param {string} [opts.file] - Disambiguate by file
|
|
1166
|
+
* @param {number} [opts.offset=0] - Skip first N commits
|
|
1167
|
+
* @param {number} [opts.limit=20] - Max commits to show
|
|
1168
|
+
* @param {boolean} [opts.patch=false] - Include function-scoped diffs
|
|
1169
|
+
* @returns {object|null} { definition, commits, raw? }
|
|
1170
|
+
*/
|
|
1171
|
+
async history({ symbol, file, offset = 0, limit = 20, patch = false }) {
|
|
1172
|
+
await this._ensureReady();
|
|
1173
|
+
const graph = this.cache.getGraph();
|
|
1174
|
+
if (!graph) return null;
|
|
1175
|
+
|
|
1176
|
+
const candidates = [];
|
|
1177
|
+
graph.forEachNode((node, attrs) => {
|
|
1178
|
+
if (attrs.type !== 'symbol') return;
|
|
1179
|
+
if (attrs.name !== symbol) return;
|
|
1180
|
+
if (file && attrs.file !== file) return;
|
|
1181
|
+
candidates.push(attrs);
|
|
1182
|
+
});
|
|
1183
|
+
if (candidates.length === 0) return null;
|
|
1184
|
+
|
|
1185
|
+
const ranked = this._getRanked();
|
|
1186
|
+
const scoreMap = new Map(ranked);
|
|
1187
|
+
candidates.sort((a, b) => {
|
|
1188
|
+
const aKey = `${a.file}::${a.name}`;
|
|
1189
|
+
const bKey = `${b.file}::${b.name}`;
|
|
1190
|
+
return (scoreMap.get(bKey) || 0) - (scoreMap.get(aKey) || 0);
|
|
1191
|
+
});
|
|
1192
|
+
const target = candidates[0];
|
|
1193
|
+
|
|
1194
|
+
const { execSync } = await import('child_process');
|
|
1195
|
+
try {
|
|
1196
|
+
if (patch) {
|
|
1197
|
+
// Full output with diffs — return raw text
|
|
1198
|
+
const raw = execSync(
|
|
1199
|
+
`git log -L ${target.lineStart},${target.lineEnd}:${target.file} --skip=${offset} -n ${limit}`,
|
|
1200
|
+
{ cwd: this.projectRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000 }
|
|
1201
|
+
).trim();
|
|
1202
|
+
return { definition: target, commits: [], raw };
|
|
1203
|
+
}
|
|
1204
|
+
// Summary only
|
|
1205
|
+
const output = execSync(
|
|
1206
|
+
`git log -L ${target.lineStart},${target.lineEnd}:${target.file} --no-patch --format="%h %ad %s" --date=short --skip=${offset} -n ${limit}`,
|
|
1207
|
+
{ cwd: this.projectRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000 }
|
|
1208
|
+
).trim();
|
|
1209
|
+
const commits = output ? output.split('\n').filter(Boolean) : [];
|
|
1210
|
+
return { definition: target, commits };
|
|
1211
|
+
} catch {
|
|
1212
|
+
return { definition: target, commits: [], error: 'git log failed — is this a git repo?' };
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
999
1216
|
/**
|
|
1000
1217
|
* Recursive caller chain — walk UP the call graph from a symbol.
|
|
1001
1218
|
* At each hop, resolves which function in the caller file contains
|