@mishasinitcyn/betterrank 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
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
@@ -24,38 +24,90 @@ function getPlatformCacheDir() {
24
24
  const CACHE_DIR = getPlatformCacheDir();
25
25
 
26
26
  const IGNORE_PATTERNS = [
27
- // Dependencies & generated
27
+ // ── JS / Node ──────────────────────────────────────────
28
28
  '**/node_modules/**',
29
- '**/dist/**',
30
- '**/build/**',
31
- '**/coverage/**',
32
- '**/vendor/**',
29
+ '**/.npm/**',
30
+ '**/.yarn/**',
31
+ '**/.pnp.*',
32
+ '**/bower_components/**',
33
33
  '**/*.min.js',
34
34
  '**/*.bundle.js',
35
35
  '**/*.map',
36
36
 
37
- // VCS & tool caches
38
- '**/.git/**',
39
- '**/.code-index/**',
40
- '**/.claude/**',
41
- '**/.cursor/**',
42
-
43
- // Language-specific caches
37
+ // ── Python ─────────────────────────────────────────────
44
38
  '**/__pycache__/**',
45
39
  '**/.venv/**',
40
+ '**/venv/**',
41
+ '**/env/**',
42
+ '**/.env/**',
43
+ '**/.virtualenvs/**',
44
+ '**/site-packages/**',
45
+ '**/*.egg-info/**',
46
+ '**/.eggs/**',
47
+ '**/.tox/**',
48
+ '**/.mypy_cache/**',
49
+ '**/.pytest_cache/**',
50
+ '**/.ruff_cache/**',
51
+
52
+ // ── Rust ───────────────────────────────────────────────
53
+ '**/target/debug/**',
54
+ '**/target/release/**',
55
+
56
+ // ── Java / JVM ─────────────────────────────────────────
57
+ '**/.gradle/**',
58
+ '**/.m2/**',
59
+
60
+ // ── Ruby ───────────────────────────────────────────────
61
+ '**/.bundle/**',
62
+
63
+ // ── C / C++ ────────────────────────────────────────────
64
+ '**/cmake-build-*/**',
65
+ '**/CMakeFiles/**',
66
+
67
+ // ── Go ─────────────────────────────────────────────────
68
+ '**/vendor/**',
69
+
70
+ // ── Build output & generated ───────────────────────────
71
+ '**/dist/**',
72
+ '**/build/**',
73
+ '**/out/**',
74
+ '**/coverage/**',
75
+
76
+ // ── Frameworks ─────────────────────────────────────────
46
77
  '**/.next/**',
47
78
  '**/.nuxt/**',
48
-
49
- // iOS / mobile vendor
79
+ '**/.output/**',
80
+ '**/.svelte-kit/**',
81
+ '**/.angular/**',
82
+ '**/.turbo/**',
83
+ '**/.parcel-cache/**',
84
+ '**/.cache/**',
85
+
86
+ // ── iOS / mobile ───────────────────────────────────────
50
87
  '**/Pods/**',
51
88
  '**/*.xcframework/**',
52
89
 
53
- // UI component libraries (shadcn, etc.) — high fan-in but rarely investigation targets.
90
+ // ── Infrastructure / deploy ────────────────────────────
91
+ '**/.terraform/**',
92
+ '**/.serverless/**',
93
+ '**/cdk.out/**',
94
+ '**/.vercel/**',
95
+ '**/.netlify/**',
96
+
97
+ // ── VCS & tool caches ──────────────────────────────────
98
+ '**/.git/**',
99
+ '**/.code-index/**',
100
+ '**/.claude/**',
101
+ '**/.cursor/**',
102
+ '**/.nx/**',
103
+
104
+ // ── UI component libraries ─────────────────────────────
105
+ // shadcn, etc. — high fan-in but rarely investigation targets.
54
106
  // To re-include, add "!**/components/ui/**" in .code-index/config.json ignore list,
55
107
  // or pass --ignore '!**/components/ui/**' on the CLI.
56
108
  '**/components/ui/**',
57
109
 
58
- // Scratch / temp
110
+ // ── Scratch / temp ─────────────────────────────────────
59
111
  'tmp/**',
60
112
  ];
61
113
 
@@ -98,7 +150,7 @@ class CodeIndexCache {
98
150
  this.initialized = true;
99
151
  }
100
152
 
101
- const { changed, deleted } = await this._getChangedFiles();
153
+ const { changed, deleted, totalScanned } = await this._getChangedFiles();
102
154
 
103
155
  if (changed.length === 0 && deleted.length === 0) {
104
156
  if (!this.graph) {
@@ -107,9 +159,15 @@ class CodeIndexCache {
107
159
  const MDG = graphology.default?.MultiDirectedGraph || graphology.MultiDirectedGraph;
108
160
  this.graph = new MDG({ allowSelfLoops: false });
109
161
  }
110
- return { changed: 0, deleted: 0 };
162
+ return { changed: 0, deleted: 0, totalScanned };
163
+ }
164
+
165
+ const isColdStart = !this.graph;
166
+ if (isColdStart) {
167
+ process.stderr.write(`Indexing ${this.projectRoot}... ${changed.length} files found, parsing...\n`);
111
168
  }
112
169
 
170
+ const t0 = Date.now();
113
171
  const newSymbols = await this._parseFiles(changed);
114
172
 
115
173
  if (!this.graph) {
@@ -123,7 +181,14 @@ class CodeIndexCache {
123
181
 
124
182
  await saveGraph(this.graph, this.mtimes, this.cachePath);
125
183
 
126
- 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 };
127
192
  }
128
193
 
129
194
  /**
@@ -204,7 +269,7 @@ class CodeIndexCache {
204
269
  }
205
270
  }
206
271
 
207
- return { changed, deleted };
272
+ return { changed, deleted, totalScanned: files.length };
208
273
  }
209
274
 
210
275
  /**
package/src/cli.js CHANGED
@@ -27,8 +27,152 @@ Global flags:
27
27
  --count Return counts only (no content)
28
28
  --offset N Skip first N results
29
29
  --limit N Max results to return (default: ${DEFAULT_LIMIT} for list commands)
30
+ --help Show help for a command (e.g. betterrank search --help)
30
31
  `.trim();
31
32
 
33
+ const COMMAND_HELP = {
34
+ map: `betterrank map [--focus file1,file2] [--root <path>]
35
+
36
+ Aider-style repo map: the most structurally important definitions ranked by PageRank.
37
+
38
+ Options:
39
+ --focus <files> Comma-separated files to bias ranking toward
40
+ --count Return total symbol count only
41
+ --offset N Skip first N symbols
42
+ --limit N Max symbols to return (default: ${DEFAULT_LIMIT})
43
+
44
+ Examples:
45
+ betterrank map --root ./backend
46
+ betterrank map --root ./frontend --limit 100
47
+ betterrank map --root ./backend --focus src/auth/handlers.ts,src/api/login.ts`,
48
+
49
+ search: `betterrank search <query> [--kind type] [--root <path>]
50
+
51
+ Substring search on symbol names + full signatures (param names, types, defaults).
52
+ Results ranked by PageRank (most structurally important first).
53
+
54
+ Options:
55
+ --kind <type> Filter: function, class, type, variable, namespace, import
56
+ --count Return match count only
57
+ --offset N Skip first N results
58
+ --limit N Max results (default: ${DEFAULT_LIMIT})
59
+
60
+ Tips:
61
+ Use short substrings (3-5 chars) — PageRank ranking handles noise.
62
+ "imp" finds encrypt_imp_payload, increment_impression, etc.
63
+ Searches match against both symbol names AND full signatures (param names, types).
64
+
65
+ Examples:
66
+ betterrank search resolve --root ./backend
67
+ betterrank search auth --kind function --limit 10
68
+ betterrank search max_age --root . --count`,
69
+
70
+ structure: `betterrank structure [--depth N] [--root <path>]
71
+
72
+ File tree with symbol counts per file.
73
+
74
+ Options:
75
+ --depth N Max directory depth (default: ${DEFAULT_DEPTH})
76
+
77
+ Examples:
78
+ betterrank structure --root ./backend --depth 2`,
79
+
80
+ symbols: `betterrank symbols [--file path] [--kind type] [--root <path>]
81
+
82
+ List symbol definitions, optionally filtered by file or kind.
83
+ Results ranked by PageRank (most structurally important first).
84
+
85
+ Options:
86
+ --file <path> Filter to a specific file (relative to --root)
87
+ --kind <type> Filter: function, class, type, variable, namespace, import
88
+ --count Return count only
89
+ --offset N Skip first N results
90
+ --limit N Max results (default: ${DEFAULT_LIMIT})
91
+
92
+ Examples:
93
+ betterrank symbols --file src/auth/handlers.ts --root ./backend
94
+ betterrank symbols --kind class --root . --limit 20`,
95
+
96
+ callers: `betterrank callers <symbol> [--file path] [--root <path>]
97
+
98
+ Find all files that reference a symbol. Ranked by file-level PageRank.
99
+
100
+ Options:
101
+ --file <path> Disambiguate when multiple symbols share a name
102
+ --count Return count only
103
+ --offset N Skip first N results
104
+ --limit N Max results (default: ${DEFAULT_LIMIT})
105
+
106
+ Examples:
107
+ betterrank callers authenticateUser --root ./backend
108
+ betterrank callers resolve --file src/utils.ts --root .`,
109
+
110
+ deps: `betterrank deps <file> [--root <path>]
111
+
112
+ What this file imports / depends on. Ranked by PageRank.
113
+
114
+ Options:
115
+ --count Return count only
116
+ --offset N Skip first N results
117
+ --limit N Max results (default: ${DEFAULT_LIMIT})
118
+
119
+ Examples:
120
+ betterrank deps src/auth/handlers.ts --root ./backend`,
121
+
122
+ dependents: `betterrank dependents <file> [--root <path>]
123
+
124
+ What files import this file. Ranked by PageRank.
125
+
126
+ Options:
127
+ --count Return count only
128
+ --offset N Skip first N results
129
+ --limit N Max results (default: ${DEFAULT_LIMIT})
130
+
131
+ Examples:
132
+ betterrank dependents src/auth/handlers.ts --root ./backend`,
133
+
134
+ neighborhood: `betterrank neighborhood <file> [--hops N] [--max-files N] [--root <path>]
135
+
136
+ Local subgraph around a file: its imports, importers, and their symbols.
137
+
138
+ Options:
139
+ --hops N BFS depth for outgoing imports (default: 2)
140
+ --max-files N Max files to include (default: 15, direct neighbors always included)
141
+ --count Return counts only
142
+ --offset N Skip first N files
143
+ --limit N Max files to return
144
+
145
+ Examples:
146
+ betterrank neighborhood src/auth/handlers.ts --root ./backend
147
+ betterrank neighborhood src/api/bid.js --hops 3 --max-files 20 --root .`,
148
+
149
+ reindex: `betterrank reindex [--root <path>]
150
+
151
+ Force a full rebuild of the index. Use after branch switches, large merges,
152
+ or if results seem stale.
153
+
154
+ Examples:
155
+ betterrank reindex --root ./backend`,
156
+
157
+ stats: `betterrank stats [--root <path>]
158
+
159
+ Show index statistics: file count, symbol count, edge count.
160
+
161
+ Examples:
162
+ betterrank stats --root .`,
163
+
164
+ ui: `betterrank ui [--port N]
165
+
166
+ Launch the interactive web UI for exploring the index.
167
+
168
+ Options:
169
+ --port N Port to listen on (default: 3333)
170
+
171
+ Examples:
172
+ betterrank ui
173
+ betterrank ui --port 8080`,
174
+ };
175
+
32
176
  async function main() {
33
177
  const args = process.argv.slice(2);
34
178
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
@@ -39,6 +183,16 @@ async function main() {
39
183
  const command = args[0];
40
184
  const flags = parseFlags(args.slice(1));
41
185
 
186
+ // Per-command --help
187
+ if (flags.help === true || flags.h === true) {
188
+ if (COMMAND_HELP[command]) {
189
+ console.log(COMMAND_HELP[command]);
190
+ } else {
191
+ console.log(USAGE);
192
+ }
193
+ process.exit(0);
194
+ }
195
+
42
196
  // UI command doesn't need --root or a CodeIndex instance
43
197
  if (command === 'ui') {
44
198
  const { startServer } = await import('./server.js');
@@ -73,6 +227,35 @@ async function main() {
73
227
  return rel;
74
228
  }
75
229
 
230
+ /** Print file-not-found diagnostics and exit. Returns true if handled. */
231
+ function handleFileNotFound(result, filePath) {
232
+ if (!result || !result.fileNotFound) return false;
233
+ console.error(`File "${filePath}" not found in index.`);
234
+ if (result.suggestions && result.suggestions.length > 0) {
235
+ console.error(`Did you mean:`);
236
+ for (const s of result.suggestions) console.error(` ${s}`);
237
+ }
238
+ console.error(`Tip: File paths are relative to --root. Use \`betterrank structure\` to see indexed files.`);
239
+ return true;
240
+ }
241
+
242
+ /** Suggest shorter query alternatives by splitting on _ and camelCase boundaries. */
243
+ function suggestShorterQueries(query) {
244
+ const parts = new Set();
245
+ // Split on underscores
246
+ for (const p of query.split('_')) {
247
+ if (p.length >= 3) parts.add(p.toLowerCase());
248
+ }
249
+ // Split on camelCase boundaries
250
+ const camelParts = query.replace(/([a-z])([A-Z])/g, '$1_$2').split('_');
251
+ for (const p of camelParts) {
252
+ if (p.length >= 3) parts.add(p.toLowerCase());
253
+ }
254
+ // Remove the original query itself
255
+ parts.delete(query.toLowerCase());
256
+ return [...parts].slice(0, 4);
257
+ }
258
+
76
259
  switch (command) {
77
260
  case 'map': {
78
261
  const focusFiles = flags.focus ? flags.focus.split(',') : [];
@@ -87,6 +270,17 @@ async function main() {
87
270
  console.log(`Use --limit N to show more, or --count for totals`);
88
271
  }
89
272
  }
273
+ // Diagnostics on empty index
274
+ if (result.diagnostics) {
275
+ const d = result.diagnostics;
276
+ console.log(`\nDiagnostics:`);
277
+ console.log(` Root: ${d.root}`);
278
+ console.log(` Files found: ${d.filesScanned}`);
279
+ console.log(` Extensions: ${d.extensions}`);
280
+ if (d.filesScanned === 0) {
281
+ console.log(` Tip: No supported files found. Try a broader --root.`);
282
+ }
283
+ }
90
284
  break;
91
285
  }
92
286
 
@@ -102,7 +296,17 @@ async function main() {
102
296
  console.log(`${s.file}:${s.lineStart} [${s.kind}] ${s.signature}`);
103
297
  }
104
298
  if (result.length === 0) {
105
- console.log('(no matches)');
299
+ const st = await idx.stats();
300
+ console.log(`(no matches for "${query}")`);
301
+ if (st.symbols > 0) {
302
+ console.log(`Index has ${st.symbols.toLocaleString()} symbols across ${st.files.toLocaleString()} files.`);
303
+ const suggestions = suggestShorterQueries(query);
304
+ if (suggestions.length > 0) {
305
+ console.log(`Tip: Use shorter substrings. Try: ${suggestions.map(s => `"${s}"`).join(', ')}`);
306
+ }
307
+ } else {
308
+ console.log(`Index is empty. Check --root or run: betterrank map --root <path>`);
309
+ }
106
310
  } else if (result.length === effectiveLimit && userLimit === undefined) {
107
311
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
108
312
  }
@@ -163,13 +367,15 @@ async function main() {
163
367
  if (!file) { console.error('Usage: betterrank deps <file>'); process.exit(1); }
164
368
  const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
165
369
  const result = await idx.dependencies({ file, count: countMode, offset, limit: effectiveLimit });
370
+ if (handleFileNotFound(result, file)) break;
166
371
  if (countMode) {
167
372
  console.log(`total: ${result.total}`);
168
373
  } else {
169
- for (const d of result) console.log(d);
170
- if (result.length === 0) {
374
+ const items = result.items || result;
375
+ for (const d of items) console.log(d);
376
+ if (items.length === 0) {
171
377
  console.log('(no dependencies)');
172
- } else if (result.length === effectiveLimit && userLimit === undefined) {
378
+ } else if (items.length === effectiveLimit && userLimit === undefined) {
173
379
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
174
380
  }
175
381
  }
@@ -181,13 +387,15 @@ async function main() {
181
387
  if (!file) { console.error('Usage: betterrank dependents <file>'); process.exit(1); }
182
388
  const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
183
389
  const result = await idx.dependents({ file, count: countMode, offset, limit: effectiveLimit });
390
+ if (handleFileNotFound(result, file)) break;
184
391
  if (countMode) {
185
392
  console.log(`total: ${result.total}`);
186
393
  } else {
187
- for (const d of result) console.log(d);
188
- if (result.length === 0) {
394
+ const items = result.items || result;
395
+ for (const d of items) console.log(d);
396
+ if (items.length === 0) {
189
397
  console.log('(no dependents)');
190
- } else if (result.length === effectiveLimit && userLimit === undefined) {
398
+ } else if (items.length === effectiveLimit && userLimit === undefined) {
191
399
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
192
400
  }
193
401
  }
@@ -208,6 +416,7 @@ async function main() {
208
416
  const preview = await idx.neighborhood({
209
417
  file, hops, maxFiles: maxFilesFlag, count: true,
210
418
  });
419
+ if (handleFileNotFound(preview, file)) break;
211
420
  console.log(`files: ${preview.totalFiles} (${preview.totalVisited} visited, ${preview.totalFiles} after ranking)`);
212
421
  console.log(`symbols: ${preview.totalSymbols}`);
213
422
  console.log(`edges: ${preview.totalEdges}`);
@@ -219,6 +428,7 @@ async function main() {
219
428
  file, hops, maxFiles: maxFilesFlag,
220
429
  count: countMode, offset, limit: userLimit,
221
430
  });
431
+ if (handleFileNotFound(result, file)) break;
222
432
 
223
433
  if (countMode) {
224
434
  console.log(`files: ${result.totalFiles} (${result.totalVisited} visited, ${result.totalFiles} after ranking)`);
@@ -284,7 +494,10 @@ function parseFlags(args) {
284
494
  const flags = { _positional: [] };
285
495
  let i = 0;
286
496
  while (i < args.length) {
287
- if (args[i].startsWith('--')) {
497
+ if (args[i] === '-h') {
498
+ flags.help = true;
499
+ i++;
500
+ } else if (args[i].startsWith('--')) {
288
501
  const key = args[i].substring(2);
289
502
  if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
290
503
  flags[key] = args[i + 1];
package/src/index.js CHANGED
@@ -1,8 +1,32 @@
1
1
  import { readFile } from 'fs/promises';
2
- import { join, dirname, relative, sep } from 'path';
2
+ import { join, dirname, relative, sep, basename } from 'path';
3
3
  import { CodeIndexCache } from './cache.js';
4
4
  import { rankedSymbols } from './graph.js';
5
5
 
6
+ /**
7
+ * Find file nodes in the graph that look similar to the given path.
8
+ * Uses basename matching and substring matching on the full path.
9
+ */
10
+ function findSimilarFiles(graph, filePath, maxSuggestions = 5) {
11
+ if (!graph) return [];
12
+ const base = basename(filePath);
13
+ const baseLower = base.toLowerCase();
14
+ const pathLower = filePath.toLowerCase();
15
+ const suggestions = [];
16
+
17
+ graph.forEachNode((node, attrs) => {
18
+ if (attrs.type !== 'file') return;
19
+ const nodeLower = node.toLowerCase();
20
+ const nodeBase = basename(node).toLowerCase();
21
+ // Exact basename match or basename contains query
22
+ if (nodeBase === baseLower || nodeBase.includes(baseLower) || nodeLower.includes(pathLower)) {
23
+ suggestions.push(node);
24
+ }
25
+ });
26
+
27
+ return suggestions.slice(0, maxSuggestions);
28
+ }
29
+
6
30
  /**
7
31
  * Apply offset/limit pagination to an array.
8
32
  * Returns { items, total } where total is the unpaginated count.
@@ -78,13 +102,18 @@ class CodeIndex {
78
102
  * @param {boolean} [opts.count] - If true, return only { total }
79
103
  * @returns {{content, shownFiles, shownSymbols, totalFiles, totalSymbols}|{total: number}}
80
104
  */
81
- async map({ focusFiles = [], offset, limit, count = false } = {}) {
82
- await this._ensureReady();
105
+ async map({ focusFiles = [], offset, limit, count = false, structured = false } = {}) {
106
+ const ensureResult = await this._ensureReady();
83
107
  const graph = this.cache.getGraph();
84
108
  if (!graph || graph.order === 0) {
85
- return count
86
- ? { total: 0 }
87
- : { content: '(empty index)', shownFiles: 0, shownSymbols: 0, totalFiles: 0, totalSymbols: 0 };
109
+ const diagnostics = {
110
+ root: this.projectRoot,
111
+ filesScanned: ensureResult.totalScanned || 0,
112
+ extensions: this.cache.extensions.join(', '),
113
+ };
114
+ if (count) return { total: 0, diagnostics };
115
+ if (structured) return { files: [], shownFiles: 0, shownSymbols: 0, totalFiles: 0, totalSymbols: 0, diagnostics };
116
+ return { content: '(empty index)', shownFiles: 0, shownSymbols: 0, totalFiles: 0, totalSymbols: 0, diagnostics };
88
117
  }
89
118
 
90
119
  // Count totals from the graph
@@ -97,7 +126,7 @@ class CodeIndex {
97
126
 
98
127
  const ranked = this._getRanked(focusFiles);
99
128
 
100
- // Collect all symbol entries ranked by PageRank
129
+ // Collect all symbol entries ranked by PageRank (rich data for both formats)
101
130
  const allEntries = [];
102
131
  for (const [symbolKey, _score] of ranked) {
103
132
  let attrs;
@@ -108,18 +137,48 @@ class CodeIndex {
108
137
  }
109
138
  if (attrs.type !== 'symbol') continue;
110
139
 
111
- const line = ` ${String(attrs.lineStart).padStart(4)}│ ${attrs.signature}`;
112
- allEntries.push({ file: attrs.file, line });
140
+ allEntries.push({
141
+ file: attrs.file,
142
+ name: attrs.name,
143
+ kind: attrs.kind,
144
+ lineStart: attrs.lineStart,
145
+ lineEnd: attrs.lineEnd,
146
+ signature: attrs.signature,
147
+ });
113
148
  }
114
149
 
115
150
  if (count) return { total: allEntries.length };
116
151
 
117
152
  const { items } = paginate(allEntries, { offset, limit });
118
153
 
154
+ // Structured format: return file objects with nested symbol arrays
155
+ if (structured) {
156
+ const fileGroups = new Map();
157
+ for (const entry of items) {
158
+ if (!fileGroups.has(entry.file)) fileGroups.set(entry.file, []);
159
+ fileGroups.get(entry.file).push({
160
+ name: entry.name,
161
+ kind: entry.kind,
162
+ lineStart: entry.lineStart,
163
+ lineEnd: entry.lineEnd,
164
+ signature: entry.signature,
165
+ });
166
+ }
167
+ return {
168
+ files: [...fileGroups.entries()].map(([path, symbols]) => ({ path, symbols })),
169
+ shownFiles: fileGroups.size,
170
+ shownSymbols: items.length,
171
+ totalFiles,
172
+ totalSymbols,
173
+ };
174
+ }
175
+
176
+ // Text format (CLI output)
119
177
  const fileGroups = new Map();
120
178
  for (const entry of items) {
121
179
  if (!fileGroups.has(entry.file)) fileGroups.set(entry.file, []);
122
- fileGroups.get(entry.file).push(entry.line);
180
+ const line = ` ${String(entry.lineStart).padStart(4)}│ ${entry.signature}`;
181
+ fileGroups.get(entry.file).push(line);
123
182
  }
124
183
 
125
184
  const lines = [];
@@ -332,7 +391,11 @@ class CodeIndex {
332
391
  async dependencies({ file, offset, limit, count = false }) {
333
392
  await this._ensureReady();
334
393
  const graph = this.cache.getGraph();
335
- if (!graph || !graph.hasNode(file)) return count ? { total: 0 } : [];
394
+ if (!graph || !graph.hasNode(file)) {
395
+ const suggestions = findSimilarFiles(graph, file);
396
+ if (count) return { total: 0, fileNotFound: true, suggestions };
397
+ return { items: [], fileNotFound: true, suggestions };
398
+ }
336
399
 
337
400
  const fileScores = this._getFileScores();
338
401
 
@@ -366,7 +429,11 @@ class CodeIndex {
366
429
  async dependents({ file, offset, limit, count = false }) {
367
430
  await this._ensureReady();
368
431
  const graph = this.cache.getGraph();
369
- if (!graph || !graph.hasNode(file)) return count ? { total: 0 } : [];
432
+ if (!graph || !graph.hasNode(file)) {
433
+ const suggestions = findSimilarFiles(graph, file);
434
+ if (count) return { total: 0, fileNotFound: true, suggestions };
435
+ return { items: [], fileNotFound: true, suggestions };
436
+ }
370
437
 
371
438
  const fileScores = this._getFileScores();
372
439
 
@@ -420,9 +487,10 @@ class CodeIndex {
420
487
  await this._ensureReady();
421
488
  const graph = this.cache.getGraph();
422
489
  if (!graph || !graph.hasNode(file)) {
490
+ const suggestions = findSimilarFiles(graph, file);
423
491
  return count
424
- ? { totalFiles: 0, totalSymbols: 0, totalEdges: 0 }
425
- : { files: [], symbols: [], edges: [] };
492
+ ? { totalFiles: 0, totalSymbols: 0, totalEdges: 0, fileNotFound: true, suggestions }
493
+ : { files: [], symbols: [], edges: [], fileNotFound: true, suggestions };
426
494
  }
427
495
 
428
496
  // BFS over file nodes, following outgoing IMPORTS edges (dependencies)
@@ -567,6 +635,61 @@ class CodeIndex {
567
635
  };
568
636
  }
569
637
 
638
+ /**
639
+ * File-level dependency graph for visualization.
640
+ * Returns nodes (files) ranked by PageRank and IMPORTS edges between them.
641
+ *
642
+ * @param {object} opts
643
+ * @param {number} [opts.limit] - Max nodes to return (default: 500)
644
+ * @returns {{ nodes: Array<{id, label, category, score}>, edges: Array<{source, target}> }}
645
+ */
646
+ async graph({ limit = 500 } = {}) {
647
+ await this._ensureReady();
648
+ const graph = this.cache.getGraph();
649
+ if (!graph || graph.order === 0) {
650
+ return { nodes: [], edges: [] };
651
+ }
652
+
653
+ const fileScores = this._getFileScores();
654
+
655
+ // Collect file nodes with scores, sorted by PageRank
656
+ const fileEntries = [];
657
+ graph.forEachNode((node, attrs) => {
658
+ if (attrs.type !== 'file') return;
659
+ fileEntries.push({ id: node, score: fileScores.get(node) || 0 });
660
+ });
661
+ fileEntries.sort((a, b) => b.score - a.score);
662
+
663
+ // Cap to limit
664
+ const capped = fileEntries.slice(0, limit);
665
+ const cappedSet = new Set(capped.map(f => f.id));
666
+
667
+ // Build nodes with category (first path segment) and label (filename)
668
+ const nodes = capped.map(f => {
669
+ const parts = f.id.split('/');
670
+ const category = parts.length > 1 ? parts[0] : 'root';
671
+ const label = parts[parts.length - 1].replace(/\.[^.]+$/, '');
672
+ return { id: f.id, label, category, score: f.score };
673
+ });
674
+
675
+ // Collect IMPORTS edges between capped files
676
+ const edges = [];
677
+ const edgeSet = new Set();
678
+ for (const f of capped) {
679
+ graph.forEachOutEdge(f.id, (_edge, attrs, source, target) => {
680
+ if (attrs.type !== 'IMPORTS') return;
681
+ if (!cappedSet.has(target)) return;
682
+ const key = `${source}->${target}`;
683
+ if (!edgeSet.has(key)) {
684
+ edgeSet.add(key);
685
+ edges.push({ source, target });
686
+ }
687
+ });
688
+ }
689
+
690
+ return { nodes, edges };
691
+ }
692
+
570
693
  /**
571
694
  * Force a full rebuild.
572
695
  */