@mishasinitcyn/betterrank 0.1.0
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 +51 -0
- package/src/cache.js +234 -0
- package/src/cli.js +293 -0
- package/src/graph.js +311 -0
- package/src/index.js +615 -0
- package/src/parser.js +333 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join, dirname, relative, sep } from 'path';
|
|
3
|
+
import { CodeIndexCache } from './cache.js';
|
|
4
|
+
import { rankedSymbols } from './graph.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply offset/limit pagination to an array.
|
|
8
|
+
* Returns { items, total } where total is the unpaginated count.
|
|
9
|
+
*/
|
|
10
|
+
function paginate(arr, { offset = 0, limit } = {}) {
|
|
11
|
+
const total = arr.length;
|
|
12
|
+
const start = Math.max(0, offset);
|
|
13
|
+
const items = limit !== undefined ? arr.slice(start, start + limit) : arr.slice(start);
|
|
14
|
+
return { items, total };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class CodeIndex {
|
|
18
|
+
constructor(projectRoot, opts = {}) {
|
|
19
|
+
this.projectRoot = projectRoot;
|
|
20
|
+
this.cache = new CodeIndexCache(projectRoot, opts);
|
|
21
|
+
this._rankedCache = null;
|
|
22
|
+
this._fileScoresCache = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async _ensureReady() {
|
|
26
|
+
const result = await this.cache.ensure();
|
|
27
|
+
if (result.changed > 0 || result.deleted > 0) {
|
|
28
|
+
this._rankedCache = null;
|
|
29
|
+
this._fileScoresCache = null;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lazy-cached PageRank scores for all symbols.
|
|
36
|
+
* Unfocused (no bias) results are cached for the session.
|
|
37
|
+
*/
|
|
38
|
+
_getRanked(focusFiles = []) {
|
|
39
|
+
if (focusFiles.length === 0 && this._rankedCache) {
|
|
40
|
+
return this._rankedCache;
|
|
41
|
+
}
|
|
42
|
+
const graph = this.cache.getGraph();
|
|
43
|
+
if (!graph || graph.order === 0) return [];
|
|
44
|
+
const ranked = rankedSymbols(graph, focusFiles);
|
|
45
|
+
if (focusFiles.length === 0) {
|
|
46
|
+
this._rankedCache = ranked;
|
|
47
|
+
}
|
|
48
|
+
return ranked;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lazy-cached file-level PageRank scores (sum of symbol scores per file).
|
|
53
|
+
*/
|
|
54
|
+
_getFileScores() {
|
|
55
|
+
if (this._fileScoresCache) return this._fileScoresCache;
|
|
56
|
+
const ranked = this._getRanked();
|
|
57
|
+
const graph = this.cache.getGraph();
|
|
58
|
+
const scores = new Map();
|
|
59
|
+
for (const [symbolKey, score] of ranked) {
|
|
60
|
+
try {
|
|
61
|
+
const attrs = graph.getNodeAttributes(symbolKey);
|
|
62
|
+
if (attrs.type !== 'symbol') continue;
|
|
63
|
+
scores.set(attrs.file, (scores.get(attrs.file) || 0) + score);
|
|
64
|
+
} catch { continue; }
|
|
65
|
+
}
|
|
66
|
+
this._fileScoresCache = scores;
|
|
67
|
+
return scores;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Aider-style repo map: a compact summary of the most structurally
|
|
72
|
+
* important definitions and their signatures, ranked by PageRank.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} opts
|
|
75
|
+
* @param {string[]} [opts.focusFiles] - Bias ranking toward these files
|
|
76
|
+
* @param {number} [opts.offset] - Skip first N symbols
|
|
77
|
+
* @param {number} [opts.limit] - Max symbols to return (default: 50)
|
|
78
|
+
* @param {boolean} [opts.count] - If true, return only { total }
|
|
79
|
+
* @returns {{content, shownFiles, shownSymbols, totalFiles, totalSymbols}|{total: number}}
|
|
80
|
+
*/
|
|
81
|
+
async map({ focusFiles = [], offset, limit, count = false } = {}) {
|
|
82
|
+
await this._ensureReady();
|
|
83
|
+
const graph = this.cache.getGraph();
|
|
84
|
+
if (!graph || graph.order === 0) {
|
|
85
|
+
return count
|
|
86
|
+
? { total: 0 }
|
|
87
|
+
: { content: '(empty index)', shownFiles: 0, shownSymbols: 0, totalFiles: 0, totalSymbols: 0 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Count totals from the graph
|
|
91
|
+
let totalFiles = 0;
|
|
92
|
+
let totalSymbols = 0;
|
|
93
|
+
graph.forEachNode((_node, attrs) => {
|
|
94
|
+
if (attrs.type === 'file') totalFiles++;
|
|
95
|
+
else if (attrs.type === 'symbol') totalSymbols++;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const ranked = this._getRanked(focusFiles);
|
|
99
|
+
|
|
100
|
+
// Collect all symbol entries ranked by PageRank
|
|
101
|
+
const allEntries = [];
|
|
102
|
+
for (const [symbolKey, _score] of ranked) {
|
|
103
|
+
let attrs;
|
|
104
|
+
try {
|
|
105
|
+
attrs = graph.getNodeAttributes(symbolKey);
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (attrs.type !== 'symbol') continue;
|
|
110
|
+
|
|
111
|
+
const line = ` ${String(attrs.lineStart).padStart(4)}│ ${attrs.signature}`;
|
|
112
|
+
allEntries.push({ file: attrs.file, line });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (count) return { total: allEntries.length };
|
|
116
|
+
|
|
117
|
+
const { items } = paginate(allEntries, { offset, limit });
|
|
118
|
+
|
|
119
|
+
const fileGroups = new Map();
|
|
120
|
+
for (const entry of items) {
|
|
121
|
+
if (!fileGroups.has(entry.file)) fileGroups.set(entry.file, []);
|
|
122
|
+
fileGroups.get(entry.file).push(entry.line);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const lines = [];
|
|
126
|
+
for (const [file, entries] of fileGroups) {
|
|
127
|
+
lines.push(`${file}:`);
|
|
128
|
+
lines.push(...entries);
|
|
129
|
+
lines.push('');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
content: lines.join('\n').trimEnd(),
|
|
134
|
+
shownFiles: fileGroups.size,
|
|
135
|
+
shownSymbols: items.length,
|
|
136
|
+
totalFiles,
|
|
137
|
+
totalSymbols,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* File tree with symbol counts per file.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} [opts]
|
|
145
|
+
* @param {number} [opts.depth] - Max directory depth (default unlimited)
|
|
146
|
+
* @returns {string} Formatted tree
|
|
147
|
+
*/
|
|
148
|
+
async structure({ depth } = {}) {
|
|
149
|
+
await this._ensureReady();
|
|
150
|
+
const graph = this.cache.getGraph();
|
|
151
|
+
if (!graph || graph.order === 0) return '(empty index)';
|
|
152
|
+
|
|
153
|
+
const fileNodes = [];
|
|
154
|
+
graph.forEachNode((node, attrs) => {
|
|
155
|
+
if (attrs.type === 'file') {
|
|
156
|
+
fileNodes.push({ path: node, symbolCount: attrs.symbolCount || 0 });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
fileNodes.sort((a, b) => a.path.localeCompare(b.path));
|
|
161
|
+
|
|
162
|
+
const tree = {};
|
|
163
|
+
for (const { path: filePath, symbolCount } of fileNodes) {
|
|
164
|
+
const parts = filePath.split(/[/\\]/);
|
|
165
|
+
if (depth !== undefined && parts.length > depth + 1) continue;
|
|
166
|
+
|
|
167
|
+
let current = tree;
|
|
168
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
169
|
+
if (!current[parts[i]]) current[parts[i]] = {};
|
|
170
|
+
current = current[parts[i]];
|
|
171
|
+
}
|
|
172
|
+
current[parts[parts.length - 1]] = symbolCount;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return formatTree(tree, '');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Fuzzy-search symbols by substring matching against name and signature
|
|
180
|
+
* (which includes parameter names and types). Results ranked by PageRank.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} opts
|
|
183
|
+
* @param {string} opts.query - Substring to match (case-insensitive)
|
|
184
|
+
* @param {string} [opts.kind] - Filter to this kind (function, class, type, variable)
|
|
185
|
+
* @param {number} [opts.offset] - Skip first N results
|
|
186
|
+
* @param {number} [opts.limit] - Max results to return
|
|
187
|
+
* @param {boolean} [opts.count] - If true, return only { total }
|
|
188
|
+
* @returns {Array|{total: number}}
|
|
189
|
+
*/
|
|
190
|
+
async search({ query, kind, offset, limit, count = false }) {
|
|
191
|
+
await this._ensureReady();
|
|
192
|
+
const graph = this.cache.getGraph();
|
|
193
|
+
if (!graph || graph.order === 0) return count ? { total: 0 } : [];
|
|
194
|
+
|
|
195
|
+
const ranked = this._getRanked();
|
|
196
|
+
const scoreMap = new Map(ranked);
|
|
197
|
+
const q = query.toLowerCase();
|
|
198
|
+
|
|
199
|
+
const results = [];
|
|
200
|
+
graph.forEachNode((node, attrs) => {
|
|
201
|
+
if (attrs.type !== 'symbol') return;
|
|
202
|
+
if (kind && attrs.kind !== kind) return;
|
|
203
|
+
|
|
204
|
+
const nameMatch = attrs.name.toLowerCase().includes(q);
|
|
205
|
+
const sigMatch = attrs.signature && attrs.signature.toLowerCase().includes(q);
|
|
206
|
+
if (!nameMatch && !sigMatch) return;
|
|
207
|
+
|
|
208
|
+
results.push({
|
|
209
|
+
name: attrs.name,
|
|
210
|
+
kind: attrs.kind,
|
|
211
|
+
file: attrs.file,
|
|
212
|
+
lineStart: attrs.lineStart,
|
|
213
|
+
lineEnd: attrs.lineEnd,
|
|
214
|
+
signature: attrs.signature,
|
|
215
|
+
_score: scoreMap.get(node) || 0,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
results.sort((a, b) => b._score - a._score);
|
|
220
|
+
for (const r of results) delete r._score;
|
|
221
|
+
|
|
222
|
+
if (count) return { total: results.length };
|
|
223
|
+
return paginate(results, { offset, limit }).items;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* List symbol definitions, optionally filtered.
|
|
228
|
+
* Results are ranked by PageRank (most structurally important first).
|
|
229
|
+
* Supports offset/limit pagination and count-only mode.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} [opts]
|
|
232
|
+
* @param {string} [opts.file] - Filter to this file
|
|
233
|
+
* @param {string} [opts.kind] - Filter to this kind (function, class, type, variable)
|
|
234
|
+
* @param {number} [opts.offset] - Skip first N results
|
|
235
|
+
* @param {number} [opts.limit] - Max results to return
|
|
236
|
+
* @param {boolean} [opts.count] - If true, return only { total }
|
|
237
|
+
* @returns {Array|{total: number}}
|
|
238
|
+
*/
|
|
239
|
+
async symbols({ file, kind, offset, limit, count = false } = {}) {
|
|
240
|
+
await this._ensureReady();
|
|
241
|
+
const graph = this.cache.getGraph();
|
|
242
|
+
if (!graph || graph.order === 0) return count ? { total: 0 } : [];
|
|
243
|
+
|
|
244
|
+
const ranked = this._getRanked();
|
|
245
|
+
const scoreMap = new Map(ranked);
|
|
246
|
+
|
|
247
|
+
const results = [];
|
|
248
|
+
graph.forEachNode((node, attrs) => {
|
|
249
|
+
if (attrs.type !== 'symbol') return;
|
|
250
|
+
if (file && attrs.file !== file) return;
|
|
251
|
+
if (kind && attrs.kind !== kind) return;
|
|
252
|
+
results.push({
|
|
253
|
+
name: attrs.name,
|
|
254
|
+
kind: attrs.kind,
|
|
255
|
+
file: attrs.file,
|
|
256
|
+
lineStart: attrs.lineStart,
|
|
257
|
+
lineEnd: attrs.lineEnd,
|
|
258
|
+
signature: attrs.signature,
|
|
259
|
+
_score: scoreMap.get(node) || 0,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
results.sort((a, b) => b._score - a._score);
|
|
264
|
+
for (const r of results) delete r._score;
|
|
265
|
+
|
|
266
|
+
if (count) return { total: results.length };
|
|
267
|
+
return paginate(results, { offset, limit }).items;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* All call sites of a symbol across the codebase.
|
|
272
|
+
* Results are ranked by file-level PageRank (most important callers first).
|
|
273
|
+
* Supports offset/limit pagination and count-only mode.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} opts
|
|
276
|
+
* @param {string} opts.symbol - Symbol name
|
|
277
|
+
* @param {string} [opts.file] - Disambiguate by file
|
|
278
|
+
* @param {number} [opts.offset] - Skip first N results
|
|
279
|
+
* @param {number} [opts.limit] - Max results to return
|
|
280
|
+
* @param {boolean} [opts.count] - If true, return only { total }
|
|
281
|
+
* @returns {Array<{file}>|{total: number}}
|
|
282
|
+
*/
|
|
283
|
+
async callers({ symbol, file, offset, limit, count = false }) {
|
|
284
|
+
await this._ensureReady();
|
|
285
|
+
const graph = this.cache.getGraph();
|
|
286
|
+
if (!graph) return count ? { total: 0 } : [];
|
|
287
|
+
|
|
288
|
+
const fileScores = this._getFileScores();
|
|
289
|
+
|
|
290
|
+
const targetKeys = [];
|
|
291
|
+
graph.forEachNode((node, attrs) => {
|
|
292
|
+
if (attrs.type !== 'symbol') return;
|
|
293
|
+
if (attrs.name !== symbol) return;
|
|
294
|
+
if (file && attrs.file !== file) return;
|
|
295
|
+
targetKeys.push(node);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const callerSet = new Set();
|
|
299
|
+
const results = [];
|
|
300
|
+
|
|
301
|
+
for (const targetKey of targetKeys) {
|
|
302
|
+
graph.forEachInEdge(targetKey, (edge, attrs, source) => {
|
|
303
|
+
if (attrs.type !== 'REFERENCES') return;
|
|
304
|
+
const sourceAttrs = graph.getNodeAttributes(source);
|
|
305
|
+
const callerFile = sourceAttrs.file || source;
|
|
306
|
+
if (!callerSet.has(callerFile)) {
|
|
307
|
+
callerSet.add(callerFile);
|
|
308
|
+
results.push({ file: callerFile, _score: fileScores.get(callerFile) || 0 });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
results.sort((a, b) => b._score - a._score);
|
|
314
|
+
for (const r of results) delete r._score;
|
|
315
|
+
|
|
316
|
+
if (count) return { total: results.length };
|
|
317
|
+
return paginate(results, { offset, limit }).items;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* What this file imports / depends on.
|
|
322
|
+
* Results are ranked by file-level PageRank (most important dependencies first).
|
|
323
|
+
* Supports offset/limit pagination and count-only mode.
|
|
324
|
+
*
|
|
325
|
+
* @param {object} opts
|
|
326
|
+
* @param {string} opts.file - File path (relative to project root)
|
|
327
|
+
* @param {number} [opts.offset] - Skip first N results
|
|
328
|
+
* @param {number} [opts.limit] - Max results to return
|
|
329
|
+
* @param {boolean} [opts.count] - If true, return only { total }
|
|
330
|
+
* @returns {string[]|{total: number}}
|
|
331
|
+
*/
|
|
332
|
+
async dependencies({ file, offset, limit, count = false }) {
|
|
333
|
+
await this._ensureReady();
|
|
334
|
+
const graph = this.cache.getGraph();
|
|
335
|
+
if (!graph || !graph.hasNode(file)) return count ? { total: 0 } : [];
|
|
336
|
+
|
|
337
|
+
const fileScores = this._getFileScores();
|
|
338
|
+
|
|
339
|
+
const deps = new Set();
|
|
340
|
+
graph.forEachOutEdge(file, (edge, attrs, _source, target) => {
|
|
341
|
+
if (attrs.type === 'IMPORTS') {
|
|
342
|
+
const targetAttrs = graph.getNodeAttributes(target);
|
|
343
|
+
if (targetAttrs.type === 'file') {
|
|
344
|
+
deps.add(target);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const sorted = [...deps].sort((a, b) => (fileScores.get(b) || 0) - (fileScores.get(a) || 0));
|
|
350
|
+
if (count) return { total: sorted.length };
|
|
351
|
+
return paginate(sorted, { offset, limit }).items;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* What depends on this file.
|
|
356
|
+
* Results are ranked by file-level PageRank (most important dependents first).
|
|
357
|
+
* Supports offset/limit pagination and count-only mode.
|
|
358
|
+
*
|
|
359
|
+
* @param {object} opts
|
|
360
|
+
* @param {string} opts.file - File path (relative to project root)
|
|
361
|
+
* @param {number} [opts.offset] - Skip first N results
|
|
362
|
+
* @param {number} [opts.limit] - Max results to return
|
|
363
|
+
* @param {boolean} [opts.count] - If true, return only { total }
|
|
364
|
+
* @returns {string[]|{total: number}}
|
|
365
|
+
*/
|
|
366
|
+
async dependents({ file, offset, limit, count = false }) {
|
|
367
|
+
await this._ensureReady();
|
|
368
|
+
const graph = this.cache.getGraph();
|
|
369
|
+
if (!graph || !graph.hasNode(file)) return count ? { total: 0 } : [];
|
|
370
|
+
|
|
371
|
+
const fileScores = this._getFileScores();
|
|
372
|
+
|
|
373
|
+
const deps = new Set();
|
|
374
|
+
graph.forEachInEdge(file, (edge, attrs, source) => {
|
|
375
|
+
if (attrs.type === 'IMPORTS') {
|
|
376
|
+
const sourceAttrs = graph.getNodeAttributes(source);
|
|
377
|
+
if (sourceAttrs.type === 'file') {
|
|
378
|
+
deps.add(source);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const sorted = [...deps].sort((a, b) => (fileScores.get(b) || 0) - (fileScores.get(a) || 0));
|
|
384
|
+
if (count) return { total: sorted.length };
|
|
385
|
+
return paginate(sorted, { offset, limit }).items;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* File-level dependency neighborhood with PageRank-ranked symbol signatures.
|
|
390
|
+
*
|
|
391
|
+
* BFS walks outgoing IMPORTS edges (dependencies). Optionally includes
|
|
392
|
+
* direct dependents (files that import the starting file).
|
|
393
|
+
*
|
|
394
|
+
* Files are aggressively ranked by PageRank. Only the starting file's
|
|
395
|
+
* direct neighbors are guaranteed; further-hop files must earn their
|
|
396
|
+
* place via PageRank score. Default cap is 15 files.
|
|
397
|
+
*
|
|
398
|
+
* Supports count-only mode (returns sizes without content) and
|
|
399
|
+
* offset/limit pagination on files (symbols follow the file list).
|
|
400
|
+
*
|
|
401
|
+
* @param {object} opts
|
|
402
|
+
* @param {string} opts.file - Starting file
|
|
403
|
+
* @param {number} [opts.hops=2] - Max hops along outgoing IMPORTS edges
|
|
404
|
+
* @param {boolean} [opts.includeDependents=true] - Include files that directly import the starting file
|
|
405
|
+
* @param {number} [opts.maxFiles=15] - Max files to include (direct neighbors always included)
|
|
406
|
+
* @param {boolean} [opts.count=false] - If true, return only counts
|
|
407
|
+
* @param {number} [opts.offset] - Skip first N files in output
|
|
408
|
+
* @param {number} [opts.limit] - Max files to return (after ranking/capping)
|
|
409
|
+
* @returns {{files, symbols, edges, total?}}
|
|
410
|
+
*/
|
|
411
|
+
async neighborhood({
|
|
412
|
+
file,
|
|
413
|
+
hops = 2,
|
|
414
|
+
includeDependents = true,
|
|
415
|
+
maxFiles = 15,
|
|
416
|
+
count = false,
|
|
417
|
+
offset,
|
|
418
|
+
limit,
|
|
419
|
+
}) {
|
|
420
|
+
await this._ensureReady();
|
|
421
|
+
const graph = this.cache.getGraph();
|
|
422
|
+
if (!graph || !graph.hasNode(file)) {
|
|
423
|
+
return count
|
|
424
|
+
? { totalFiles: 0, totalSymbols: 0, totalEdges: 0 }
|
|
425
|
+
: { files: [], symbols: [], edges: [] };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// BFS over file nodes, following outgoing IMPORTS edges (dependencies)
|
|
429
|
+
// Track hop depth per file for ranking
|
|
430
|
+
const fileHops = new Map(); // file -> hop distance
|
|
431
|
+
const queue = [{ node: file, depth: 0 }];
|
|
432
|
+
fileHops.set(file, 0);
|
|
433
|
+
|
|
434
|
+
while (queue.length > 0) {
|
|
435
|
+
const { node, depth } = queue.shift();
|
|
436
|
+
if (depth >= hops) continue;
|
|
437
|
+
|
|
438
|
+
graph.forEachOutEdge(node, (edge, attrs, _source, target) => {
|
|
439
|
+
if (attrs.type !== 'IMPORTS') return;
|
|
440
|
+
const targetAttrs = graph.getNodeAttributes(target);
|
|
441
|
+
if (targetAttrs.type !== 'file') return;
|
|
442
|
+
if (!fileHops.has(target)) {
|
|
443
|
+
fileHops.set(target, depth + 1);
|
|
444
|
+
queue.push({ node: target, depth: depth + 1 });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Direct dependents (hop "0.5" — they import the starting file)
|
|
450
|
+
const directDependents = new Set();
|
|
451
|
+
if (includeDependents) {
|
|
452
|
+
graph.forEachInEdge(file, (edge, attrs, source) => {
|
|
453
|
+
if (attrs.type !== 'IMPORTS') return;
|
|
454
|
+
const sourceAttrs = graph.getNodeAttributes(source);
|
|
455
|
+
if (sourceAttrs.type !== 'file') return;
|
|
456
|
+
directDependents.add(source);
|
|
457
|
+
if (!fileHops.has(source)) {
|
|
458
|
+
fileHops.set(source, 1);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Identify direct neighbors (hop 1 deps + direct dependents)
|
|
464
|
+
const directFiles = new Set([file]);
|
|
465
|
+
graph.forEachOutEdge(file, (edge, attrs, _source, target) => {
|
|
466
|
+
if (attrs.type === 'IMPORTS') directFiles.add(target);
|
|
467
|
+
});
|
|
468
|
+
for (const d of directDependents) directFiles.add(d);
|
|
469
|
+
|
|
470
|
+
// Rank all visited files by PageRank, biased toward starting file
|
|
471
|
+
const ranked = this._getRanked([file]);
|
|
472
|
+
const prMap = new Map(ranked);
|
|
473
|
+
|
|
474
|
+
// Score files: direct neighbors get a large bonus, then PageRank
|
|
475
|
+
const allVisited = [...fileHops.keys()];
|
|
476
|
+
const fileScored = allVisited.map(f => {
|
|
477
|
+
const isDirect = directFiles.has(f);
|
|
478
|
+
const hopDist = fileHops.get(f) || 99;
|
|
479
|
+
// Sum PageRank of symbols in this file
|
|
480
|
+
let filePR = 0;
|
|
481
|
+
graph.forEachOutEdge(f, (edge, attrs, _source, target) => {
|
|
482
|
+
if (attrs.type === 'DEFINES') {
|
|
483
|
+
filePR += prMap.get(target) || 0;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
// Direct neighbors always sort first; within each tier, sort by PageRank
|
|
487
|
+
const score = (isDirect ? 1e6 : 0) + filePR * 1e4 - hopDist;
|
|
488
|
+
return { file: f, score, isDirect };
|
|
489
|
+
});
|
|
490
|
+
fileScored.sort((a, b) => b.score - a.score);
|
|
491
|
+
|
|
492
|
+
// Cap: always include direct neighbors, then fill up to maxFiles with ranked hop-2+ files
|
|
493
|
+
const cappedFiles = [];
|
|
494
|
+
const cappedSet = new Set();
|
|
495
|
+
for (const entry of fileScored) {
|
|
496
|
+
if (entry.isDirect || cappedFiles.length < maxFiles) {
|
|
497
|
+
cappedFiles.push(entry.file);
|
|
498
|
+
cappedSet.add(entry.file);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Collect IMPORTS edges involving the starting file only
|
|
503
|
+
const edges = [];
|
|
504
|
+
const edgeSet = new Set();
|
|
505
|
+
graph.forEachOutEdge(file, (edge, attrs, source, target) => {
|
|
506
|
+
if (attrs.type !== 'IMPORTS') return;
|
|
507
|
+
if (!cappedSet.has(target)) return;
|
|
508
|
+
const key = `${source}->${target}`;
|
|
509
|
+
if (!edgeSet.has(key)) {
|
|
510
|
+
edgeSet.add(key);
|
|
511
|
+
edges.push({ source, target, type: 'IMPORTS' });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
graph.forEachInEdge(file, (edge, attrs, source) => {
|
|
515
|
+
if (attrs.type !== 'IMPORTS') return;
|
|
516
|
+
if (!cappedSet.has(source)) return;
|
|
517
|
+
const key = `${source}->${file}`;
|
|
518
|
+
if (!edgeSet.has(key)) {
|
|
519
|
+
edgeSet.add(key);
|
|
520
|
+
edges.push({ source, target: file, type: 'IMPORTS' });
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Collect all symbols from capped files, ranked by PageRank
|
|
525
|
+
const symbols = [];
|
|
526
|
+
for (const [symbolKey, _score] of ranked) {
|
|
527
|
+
let attrs;
|
|
528
|
+
try {
|
|
529
|
+
attrs = graph.getNodeAttributes(symbolKey);
|
|
530
|
+
} catch {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (attrs.type !== 'symbol') continue;
|
|
534
|
+
if (!cappedSet.has(attrs.file)) continue;
|
|
535
|
+
|
|
536
|
+
symbols.push({
|
|
537
|
+
name: attrs.name,
|
|
538
|
+
kind: attrs.kind,
|
|
539
|
+
file: attrs.file,
|
|
540
|
+
lineStart: attrs.lineStart,
|
|
541
|
+
signature: attrs.signature,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (count) {
|
|
546
|
+
return {
|
|
547
|
+
totalFiles: cappedFiles.length,
|
|
548
|
+
totalSymbols: symbols.length,
|
|
549
|
+
totalEdges: edges.length,
|
|
550
|
+
totalVisited: allVisited.length,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Apply pagination to files (symbols follow the file list)
|
|
555
|
+
const { items: paginatedFiles } = paginate(cappedFiles, { offset, limit });
|
|
556
|
+
const paginatedFileSet = new Set(paginatedFiles);
|
|
557
|
+
const paginatedSymbols = symbols.filter(s => paginatedFileSet.has(s.file));
|
|
558
|
+
const paginatedEdges = edges.filter(
|
|
559
|
+
e => paginatedFileSet.has(e.source) || paginatedFileSet.has(e.target)
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
files: paginatedFiles,
|
|
564
|
+
symbols: paginatedSymbols,
|
|
565
|
+
edges: paginatedEdges,
|
|
566
|
+
total: cappedFiles.length,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Force a full rebuild.
|
|
572
|
+
*/
|
|
573
|
+
async reindex() {
|
|
574
|
+
this._rankedCache = null;
|
|
575
|
+
this._fileScoresCache = null;
|
|
576
|
+
return this.cache.reindex();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Get index stats.
|
|
581
|
+
*/
|
|
582
|
+
async stats() {
|
|
583
|
+
await this._ensureReady();
|
|
584
|
+
const graph = this.cache.getGraph();
|
|
585
|
+
if (!graph) return { files: 0, symbols: 0, edges: 0 };
|
|
586
|
+
|
|
587
|
+
let files = 0;
|
|
588
|
+
let symbols = 0;
|
|
589
|
+
graph.forEachNode((_node, attrs) => {
|
|
590
|
+
if (attrs.type === 'file') files++;
|
|
591
|
+
else if (attrs.type === 'symbol') symbols++;
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
return { files, symbols, edges: graph.size };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function formatTree(obj, indent) {
|
|
599
|
+
const lines = [];
|
|
600
|
+
const entries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b));
|
|
601
|
+
|
|
602
|
+
for (const [key, value] of entries) {
|
|
603
|
+
if (typeof value === 'number') {
|
|
604
|
+
lines.push(`${indent}${key} (${value} symbols)`);
|
|
605
|
+
} else {
|
|
606
|
+
lines.push(`${indent}${key}/`);
|
|
607
|
+
lines.push(formatTree(value, indent + ' '));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return lines.join('\n');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export { CodeIndex };
|
|
615
|
+
export default CodeIndex;
|