@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 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.1",
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