@mishasinitcyn/betterrank 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mishasinitcyn/betterrank",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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/cache.js CHANGED
@@ -150,7 +150,7 @@ class CodeIndexCache {
150
150
  this.initialized = true;
151
151
  }
152
152
 
153
- const { changed, deleted } = await this._getChangedFiles();
153
+ const { changed, deleted, totalScanned } = await this._getChangedFiles();
154
154
 
155
155
  if (changed.length === 0 && deleted.length === 0) {
156
156
  if (!this.graph) {
@@ -159,9 +159,15 @@ class CodeIndexCache {
159
159
  const MDG = graphology.default?.MultiDirectedGraph || graphology.MultiDirectedGraph;
160
160
  this.graph = new MDG({ allowSelfLoops: false });
161
161
  }
162
- return { changed: 0, deleted: 0 };
162
+ return { changed: 0, deleted: 0, totalScanned };
163
163
  }
164
164
 
165
+ const isColdStart = !this.graph;
166
+ if (isColdStart) {
167
+ process.stderr.write(`Indexing ${this.projectRoot}... ${changed.length} files found, parsing...\n`);
168
+ }
169
+
170
+ const t0 = Date.now();
165
171
  const newSymbols = await this._parseFiles(changed);
166
172
 
167
173
  if (!this.graph) {
@@ -175,7 +181,14 @@ class CodeIndexCache {
175
181
 
176
182
  await saveGraph(this.graph, this.mtimes, this.cachePath);
177
183
 
178
- return { changed: changed.length, deleted: deleted.length };
184
+ if (isColdStart) {
185
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
186
+ let symbols = 0;
187
+ this.graph.forEachNode((_n, attrs) => { if (attrs.type === 'symbol') symbols++; });
188
+ process.stderr.write(`Indexed ${changed.length} files (${symbols} symbols, ${this.graph.size} edges) in ${elapsed}s\n`);
189
+ }
190
+
191
+ return { changed: changed.length, deleted: deleted.length, totalScanned };
179
192
  }
180
193
 
181
194
  /**
@@ -256,7 +269,7 @@ class CodeIndexCache {
256
269
  }
257
270
  }
258
271
 
259
- return { changed, deleted };
272
+ return { changed, deleted, totalScanned: files.length };
260
273
  }
261
274
 
262
275
  /**
package/src/cli.js CHANGED
@@ -19,6 +19,7 @@ Commands:
19
19
  deps <file> What this file imports (ranked)
20
20
  dependents <file> What imports this file (ranked)
21
21
  neighborhood <file> [--hops N] [--max-files N] Local subgraph (ranked by PageRank)
22
+ orphans [--level file|symbol] [--kind type] Find disconnected files/symbols
22
23
  reindex Force full rebuild
23
24
  stats Index statistics
24
25
 
@@ -27,8 +28,176 @@ Global flags:
27
28
  --count Return counts only (no content)
28
29
  --offset N Skip first N results
29
30
  --limit N Max results to return (default: ${DEFAULT_LIMIT} for list commands)
31
+ --help Show help for a command (e.g. betterrank search --help)
30
32
  `.trim();
31
33
 
34
+ const COMMAND_HELP = {
35
+ map: `betterrank map [--focus file1,file2] [--root <path>]
36
+
37
+ Aider-style repo map: the most structurally important definitions ranked by PageRank.
38
+
39
+ Options:
40
+ --focus <files> Comma-separated files to bias ranking toward
41
+ --count Return total symbol count only
42
+ --offset N Skip first N symbols
43
+ --limit N Max symbols to return (default: ${DEFAULT_LIMIT})
44
+
45
+ Examples:
46
+ betterrank map --root ./backend
47
+ betterrank map --root ./frontend --limit 100
48
+ betterrank map --root ./backend --focus src/auth/handlers.ts,src/api/login.ts`,
49
+
50
+ search: `betterrank search <query> [--kind type] [--root <path>]
51
+
52
+ Substring search on symbol names + full signatures (param names, types, defaults).
53
+ Results ranked by PageRank (most structurally important first).
54
+
55
+ Options:
56
+ --kind <type> Filter: function, class, type, variable, namespace, import
57
+ --count Return match count only
58
+ --offset N Skip first N results
59
+ --limit N Max results (default: ${DEFAULT_LIMIT})
60
+
61
+ Tips:
62
+ Use short substrings (3-5 chars) — PageRank ranking handles noise.
63
+ "imp" finds encrypt_imp_payload, increment_impression, etc.
64
+ Searches match against both symbol names AND full signatures (param names, types).
65
+
66
+ Examples:
67
+ betterrank search resolve --root ./backend
68
+ betterrank search auth --kind function --limit 10
69
+ betterrank search max_age --root . --count`,
70
+
71
+ structure: `betterrank structure [--depth N] [--root <path>]
72
+
73
+ File tree with symbol counts per file.
74
+
75
+ Options:
76
+ --depth N Max directory depth (default: ${DEFAULT_DEPTH})
77
+
78
+ Examples:
79
+ betterrank structure --root ./backend --depth 2`,
80
+
81
+ symbols: `betterrank symbols [--file path] [--kind type] [--root <path>]
82
+
83
+ List symbol definitions, optionally filtered by file or kind.
84
+ Results ranked by PageRank (most structurally important first).
85
+
86
+ Options:
87
+ --file <path> Filter to a specific file (relative to --root)
88
+ --kind <type> Filter: function, class, type, variable, namespace, import
89
+ --count Return count only
90
+ --offset N Skip first N results
91
+ --limit N Max results (default: ${DEFAULT_LIMIT})
92
+
93
+ Examples:
94
+ betterrank symbols --file src/auth/handlers.ts --root ./backend
95
+ betterrank symbols --kind class --root . --limit 20`,
96
+
97
+ callers: `betterrank callers <symbol> [--file path] [--root <path>]
98
+
99
+ Find all files that reference a symbol. Ranked by file-level PageRank.
100
+
101
+ Options:
102
+ --file <path> Disambiguate when multiple symbols share a name
103
+ --count Return count only
104
+ --offset N Skip first N results
105
+ --limit N Max results (default: ${DEFAULT_LIMIT})
106
+
107
+ Examples:
108
+ betterrank callers authenticateUser --root ./backend
109
+ betterrank callers resolve --file src/utils.ts --root .`,
110
+
111
+ deps: `betterrank deps <file> [--root <path>]
112
+
113
+ What this file imports / depends on. Ranked by PageRank.
114
+
115
+ Options:
116
+ --count Return count only
117
+ --offset N Skip first N results
118
+ --limit N Max results (default: ${DEFAULT_LIMIT})
119
+
120
+ Examples:
121
+ betterrank deps src/auth/handlers.ts --root ./backend`,
122
+
123
+ dependents: `betterrank dependents <file> [--root <path>]
124
+
125
+ What files import this file. Ranked by PageRank.
126
+
127
+ Options:
128
+ --count Return count only
129
+ --offset N Skip first N results
130
+ --limit N Max results (default: ${DEFAULT_LIMIT})
131
+
132
+ Examples:
133
+ betterrank dependents src/auth/handlers.ts --root ./backend`,
134
+
135
+ neighborhood: `betterrank neighborhood <file> [--hops N] [--max-files N] [--root <path>]
136
+
137
+ Local subgraph around a file: its imports, importers, and their symbols.
138
+
139
+ Options:
140
+ --hops N BFS depth for outgoing imports (default: 2)
141
+ --max-files N Max files to include (default: 15, direct neighbors always included)
142
+ --count Return counts only
143
+ --offset N Skip first N files
144
+ --limit N Max files to return
145
+
146
+ Examples:
147
+ betterrank neighborhood src/auth/handlers.ts --root ./backend
148
+ betterrank neighborhood src/api/bid.js --hops 3 --max-files 20 --root .`,
149
+
150
+ orphans: `betterrank orphans [--level file|symbol] [--kind type] [--root <path>]
151
+
152
+ Find disconnected files or symbols — the "satellites" in the graph UI.
153
+
154
+ Levels:
155
+ file Files with zero cross-file imports (default)
156
+ symbol Symbols never referenced from outside their own file (dead code candidates)
157
+
158
+ Options:
159
+ --level <type> "file" or "symbol" (default: file)
160
+ --kind <type> Filter symbols: function, class, type, variable (only with --level symbol)
161
+ --count Return count only
162
+ --offset N Skip first N results
163
+ --limit N Max results (default: ${DEFAULT_LIMIT})
164
+
165
+ False positives (entry points, config files, tests, framework hooks, dunders,
166
+ etc.) are automatically excluded.
167
+
168
+ Examples:
169
+ betterrank orphans --root ./backend
170
+ betterrank orphans --level symbol --root .
171
+ betterrank orphans --level symbol --kind function --root .
172
+ betterrank orphans --count --root .`,
173
+
174
+ reindex: `betterrank reindex [--root <path>]
175
+
176
+ Force a full rebuild of the index. Use after branch switches, large merges,
177
+ or if results seem stale.
178
+
179
+ Examples:
180
+ betterrank reindex --root ./backend`,
181
+
182
+ stats: `betterrank stats [--root <path>]
183
+
184
+ Show index statistics: file count, symbol count, edge count.
185
+
186
+ Examples:
187
+ betterrank stats --root .`,
188
+
189
+ ui: `betterrank ui [--port N]
190
+
191
+ Launch the interactive web UI for exploring the index.
192
+
193
+ Options:
194
+ --port N Port to listen on (default: 3333)
195
+
196
+ Examples:
197
+ betterrank ui
198
+ betterrank ui --port 8080`,
199
+ };
200
+
32
201
  async function main() {
33
202
  const args = process.argv.slice(2);
34
203
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
@@ -39,6 +208,16 @@ async function main() {
39
208
  const command = args[0];
40
209
  const flags = parseFlags(args.slice(1));
41
210
 
211
+ // Per-command --help
212
+ if (flags.help === true || flags.h === true) {
213
+ if (COMMAND_HELP[command]) {
214
+ console.log(COMMAND_HELP[command]);
215
+ } else {
216
+ console.log(USAGE);
217
+ }
218
+ process.exit(0);
219
+ }
220
+
42
221
  // UI command doesn't need --root or a CodeIndex instance
43
222
  if (command === 'ui') {
44
223
  const { startServer } = await import('./server.js');
@@ -73,6 +252,35 @@ async function main() {
73
252
  return rel;
74
253
  }
75
254
 
255
+ /** Print file-not-found diagnostics and exit. Returns true if handled. */
256
+ function handleFileNotFound(result, filePath) {
257
+ if (!result || !result.fileNotFound) return false;
258
+ console.error(`File "${filePath}" not found in index.`);
259
+ if (result.suggestions && result.suggestions.length > 0) {
260
+ console.error(`Did you mean:`);
261
+ for (const s of result.suggestions) console.error(` ${s}`);
262
+ }
263
+ console.error(`Tip: File paths are relative to --root. Use \`betterrank structure\` to see indexed files.`);
264
+ return true;
265
+ }
266
+
267
+ /** Suggest shorter query alternatives by splitting on _ and camelCase boundaries. */
268
+ function suggestShorterQueries(query) {
269
+ const parts = new Set();
270
+ // Split on underscores
271
+ for (const p of query.split('_')) {
272
+ if (p.length >= 3) parts.add(p.toLowerCase());
273
+ }
274
+ // Split on camelCase boundaries
275
+ const camelParts = query.replace(/([a-z])([A-Z])/g, '$1_$2').split('_');
276
+ for (const p of camelParts) {
277
+ if (p.length >= 3) parts.add(p.toLowerCase());
278
+ }
279
+ // Remove the original query itself
280
+ parts.delete(query.toLowerCase());
281
+ return [...parts].slice(0, 4);
282
+ }
283
+
76
284
  switch (command) {
77
285
  case 'map': {
78
286
  const focusFiles = flags.focus ? flags.focus.split(',') : [];
@@ -87,6 +295,17 @@ async function main() {
87
295
  console.log(`Use --limit N to show more, or --count for totals`);
88
296
  }
89
297
  }
298
+ // Diagnostics on empty index
299
+ if (result.diagnostics) {
300
+ const d = result.diagnostics;
301
+ console.log(`\nDiagnostics:`);
302
+ console.log(` Root: ${d.root}`);
303
+ console.log(` Files found: ${d.filesScanned}`);
304
+ console.log(` Extensions: ${d.extensions}`);
305
+ if (d.filesScanned === 0) {
306
+ console.log(` Tip: No supported files found. Try a broader --root.`);
307
+ }
308
+ }
90
309
  break;
91
310
  }
92
311
 
@@ -102,7 +321,17 @@ async function main() {
102
321
  console.log(`${s.file}:${s.lineStart} [${s.kind}] ${s.signature}`);
103
322
  }
104
323
  if (result.length === 0) {
105
- console.log('(no matches)');
324
+ const st = await idx.stats();
325
+ console.log(`(no matches for "${query}")`);
326
+ if (st.symbols > 0) {
327
+ console.log(`Index has ${st.symbols.toLocaleString()} symbols across ${st.files.toLocaleString()} files.`);
328
+ const suggestions = suggestShorterQueries(query);
329
+ if (suggestions.length > 0) {
330
+ console.log(`Tip: Use shorter substrings. Try: ${suggestions.map(s => `"${s}"`).join(', ')}`);
331
+ }
332
+ } else {
333
+ console.log(`Index is empty. Check --root or run: betterrank map --root <path>`);
334
+ }
106
335
  } else if (result.length === effectiveLimit && userLimit === undefined) {
107
336
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
108
337
  }
@@ -163,13 +392,15 @@ async function main() {
163
392
  if (!file) { console.error('Usage: betterrank deps <file>'); process.exit(1); }
164
393
  const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
165
394
  const result = await idx.dependencies({ file, count: countMode, offset, limit: effectiveLimit });
395
+ if (handleFileNotFound(result, file)) break;
166
396
  if (countMode) {
167
397
  console.log(`total: ${result.total}`);
168
398
  } else {
169
- for (const d of result) console.log(d);
170
- if (result.length === 0) {
399
+ const items = result.items || result;
400
+ for (const d of items) console.log(d);
401
+ if (items.length === 0) {
171
402
  console.log('(no dependencies)');
172
- } else if (result.length === effectiveLimit && userLimit === undefined) {
403
+ } else if (items.length === effectiveLimit && userLimit === undefined) {
173
404
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
174
405
  }
175
406
  }
@@ -181,13 +412,15 @@ async function main() {
181
412
  if (!file) { console.error('Usage: betterrank dependents <file>'); process.exit(1); }
182
413
  const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
183
414
  const result = await idx.dependents({ file, count: countMode, offset, limit: effectiveLimit });
415
+ if (handleFileNotFound(result, file)) break;
184
416
  if (countMode) {
185
417
  console.log(`total: ${result.total}`);
186
418
  } else {
187
- for (const d of result) console.log(d);
188
- if (result.length === 0) {
419
+ const items = result.items || result;
420
+ for (const d of items) console.log(d);
421
+ if (items.length === 0) {
189
422
  console.log('(no dependents)');
190
- } else if (result.length === effectiveLimit && userLimit === undefined) {
423
+ } else if (items.length === effectiveLimit && userLimit === undefined) {
191
424
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
192
425
  }
193
426
  }
@@ -208,6 +441,7 @@ async function main() {
208
441
  const preview = await idx.neighborhood({
209
442
  file, hops, maxFiles: maxFilesFlag, count: true,
210
443
  });
444
+ if (handleFileNotFound(preview, file)) break;
211
445
  console.log(`files: ${preview.totalFiles} (${preview.totalVisited} visited, ${preview.totalFiles} after ranking)`);
212
446
  console.log(`symbols: ${preview.totalSymbols}`);
213
447
  console.log(`edges: ${preview.totalEdges}`);
@@ -219,6 +453,7 @@ async function main() {
219
453
  file, hops, maxFiles: maxFilesFlag,
220
454
  count: countMode, offset, limit: userLimit,
221
455
  });
456
+ if (handleFileNotFound(result, file)) break;
222
457
 
223
458
  if (countMode) {
224
459
  console.log(`files: ${result.totalFiles} (${result.totalVisited} visited, ${result.totalFiles} after ranking)`);
@@ -255,6 +490,55 @@ async function main() {
255
490
  break;
256
491
  }
257
492
 
493
+ case 'orphans': {
494
+ const level = flags.level || 'file';
495
+ if (level !== 'file' && level !== 'symbol') {
496
+ console.error(`Unknown level: "${level}". Use "file" or "symbol".`);
497
+ process.exit(1);
498
+ }
499
+ const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
500
+ const result = await idx.orphans({ level, kind: flags.kind, count: countMode, offset, limit: effectiveLimit });
501
+
502
+ if (countMode) {
503
+ console.log(`total: ${result.total}`);
504
+ } else if (level === 'file') {
505
+ for (const f of result) {
506
+ console.log(`${f.file} (${f.symbolCount} symbols)`);
507
+ }
508
+ if (result.length === 0) {
509
+ console.log('(no orphan files found)');
510
+ } else {
511
+ const total = await idx.orphans({ level, count: true });
512
+ if (result.length < total.total) {
513
+ console.log(`\nShowing ${result.length} of ${total.total} orphan files (use --limit N for more)`);
514
+ }
515
+ }
516
+ } else {
517
+ // symbol level — group by file like map output
518
+ const byFile = new Map();
519
+ for (const s of result) {
520
+ if (!byFile.has(s.file)) byFile.set(s.file, []);
521
+ byFile.get(s.file).push(s);
522
+ }
523
+ for (const [file, syms] of byFile) {
524
+ console.log(`${file}:`);
525
+ for (const s of syms) {
526
+ console.log(` ${String(s.lineStart).padStart(4)}│ [${s.kind}] ${s.signature}`);
527
+ }
528
+ console.log('');
529
+ }
530
+ if (result.length === 0) {
531
+ console.log('(no orphan symbols found)');
532
+ } else {
533
+ const total = await idx.orphans({ level, kind: flags.kind, count: true });
534
+ if (result.length < total.total) {
535
+ console.log(`Showing ${result.length} of ${total.total} orphan symbols across ${byFile.size} files (use --limit N for more)`);
536
+ }
537
+ }
538
+ }
539
+ break;
540
+ }
541
+
258
542
  case 'reindex': {
259
543
  const t0 = Date.now();
260
544
  const result = await idx.reindex();
@@ -284,7 +568,10 @@ function parseFlags(args) {
284
568
  const flags = { _positional: [] };
285
569
  let i = 0;
286
570
  while (i < args.length) {
287
- if (args[i].startsWith('--')) {
571
+ if (args[i] === '-h') {
572
+ flags.help = true;
573
+ i++;
574
+ } else if (args[i].startsWith('--')) {
288
575
  const key = args[i].substring(2);
289
576
  if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
290
577
  flags[key] = args[i + 1];