ucn 3.0.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.
Potentially problematic release.
This version of ucn might be problematic. Click here for more details.
- package/.claude/skills/ucn/SKILL.md +77 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/cli/index.js +2437 -0
- package/core/discovery.js +513 -0
- package/core/imports.js +558 -0
- package/core/output.js +1274 -0
- package/core/parser.js +279 -0
- package/core/project.js +3261 -0
- package/index.js +52 -0
- package/languages/go.js +653 -0
- package/languages/index.js +267 -0
- package/languages/java.js +826 -0
- package/languages/javascript.js +1346 -0
- package/languages/python.js +667 -0
- package/languages/rust.js +950 -0
- package/languages/utils.js +457 -0
- package/package.json +42 -0
- package/test/fixtures/go/go.mod +3 -0
- package/test/fixtures/go/main.go +257 -0
- package/test/fixtures/go/service.go +187 -0
- package/test/fixtures/java/DataService.java +279 -0
- package/test/fixtures/java/Main.java +287 -0
- package/test/fixtures/java/Utils.java +199 -0
- package/test/fixtures/java/pom.xml +6 -0
- package/test/fixtures/javascript/main.js +109 -0
- package/test/fixtures/javascript/package.json +1 -0
- package/test/fixtures/javascript/service.js +88 -0
- package/test/fixtures/javascript/utils.js +67 -0
- package/test/fixtures/python/main.py +198 -0
- package/test/fixtures/python/pyproject.toml +3 -0
- package/test/fixtures/python/service.py +166 -0
- package/test/fixtures/python/utils.py +118 -0
- package/test/fixtures/rust/Cargo.toml +3 -0
- package/test/fixtures/rust/main.rs +253 -0
- package/test/fixtures/rust/service.rs +210 -0
- package/test/fixtures/rust/utils.rs +154 -0
- package/test/fixtures/typescript/main.ts +154 -0
- package/test/fixtures/typescript/package.json +1 -0
- package/test/fixtures/typescript/repository.ts +149 -0
- package/test/fixtures/typescript/types.ts +114 -0
- package/test/parser.test.js +3661 -0
- package/test/public-repos-test.js +477 -0
- package/test/systematic-test.js +619 -0
- package/ucn.js +8 -0
package/core/project.js
ADDED
|
@@ -0,0 +1,3261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/project.js - Project symbol table and cross-file analysis
|
|
3
|
+
*
|
|
4
|
+
* Builds an in-memory index of all symbols in a project for fast queries.
|
|
5
|
+
* Includes dependency weighting and disambiguation support.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile } = require('./discovery');
|
|
12
|
+
const { extractImports, extractExports, resolveImport } = require('./imports');
|
|
13
|
+
const { parseFile } = require('./parser');
|
|
14
|
+
const { detectLanguage, getParser, getLanguageModule, PARSE_OPTIONS } = require('../languages');
|
|
15
|
+
const { getTokenTypeAtPosition } = require('../languages/utils');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Escape special regex characters
|
|
19
|
+
*/
|
|
20
|
+
function escapeRegExp(text) {
|
|
21
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* ProjectIndex - Manages symbol table for a project
|
|
26
|
+
*/
|
|
27
|
+
class ProjectIndex {
|
|
28
|
+
/**
|
|
29
|
+
* Create a new ProjectIndex
|
|
30
|
+
* @param {string} rootDir - Project root directory
|
|
31
|
+
*/
|
|
32
|
+
constructor(rootDir) {
|
|
33
|
+
this.root = findProjectRoot(rootDir);
|
|
34
|
+
this.files = new Map(); // path -> FileEntry
|
|
35
|
+
this.symbols = new Map(); // name -> SymbolEntry[]
|
|
36
|
+
this.importGraph = new Map(); // file -> [imported files]
|
|
37
|
+
this.exportGraph = new Map(); // file -> [files that import it]
|
|
38
|
+
this.extendsGraph = new Map(); // className -> parentName
|
|
39
|
+
this.extendedByGraph = new Map(); // parentName -> [childInfo]
|
|
40
|
+
this.config = this.loadConfig();
|
|
41
|
+
this.buildTime = null;
|
|
42
|
+
this.callsCache = new Map(); // filePath -> { mtime, hash, calls, content }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load .ucn.js config if present
|
|
47
|
+
*/
|
|
48
|
+
loadConfig() {
|
|
49
|
+
const configPath = path.join(this.root, '.ucn.js');
|
|
50
|
+
if (fs.existsSync(configPath)) {
|
|
51
|
+
try {
|
|
52
|
+
delete require.cache[require.resolve(configPath)];
|
|
53
|
+
return require(configPath);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// Config load failed, use defaults
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build index for files matching pattern
|
|
63
|
+
*
|
|
64
|
+
* @param {string} pattern - Glob pattern (e.g., "**\/*.js")
|
|
65
|
+
* @param {object} options - { forceRebuild, maxFiles, quiet }
|
|
66
|
+
*/
|
|
67
|
+
build(pattern, options = {}) {
|
|
68
|
+
const startTime = Date.now();
|
|
69
|
+
const quiet = options.quiet !== false;
|
|
70
|
+
|
|
71
|
+
if (!pattern) {
|
|
72
|
+
pattern = detectProjectPattern(this.root);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const files = expandGlob(pattern, {
|
|
76
|
+
root: this.root,
|
|
77
|
+
maxFiles: options.maxFiles || 10000,
|
|
78
|
+
followSymlinks: options.followSymlinks
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!quiet) {
|
|
82
|
+
console.error(`Indexing ${files.length} files in ${this.root}...`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (options.forceRebuild) {
|
|
86
|
+
this.files.clear();
|
|
87
|
+
this.symbols.clear();
|
|
88
|
+
this.importGraph.clear();
|
|
89
|
+
this.exportGraph.clear();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let indexed = 0;
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
try {
|
|
95
|
+
this.indexFile(file);
|
|
96
|
+
indexed++;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (!quiet) {
|
|
99
|
+
console.error(` Warning: Could not index ${file}: ${e.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.buildImportGraph();
|
|
105
|
+
this.buildInheritanceGraph();
|
|
106
|
+
|
|
107
|
+
this.buildTime = Date.now() - startTime;
|
|
108
|
+
|
|
109
|
+
if (!quiet) {
|
|
110
|
+
console.error(`Index complete: ${this.symbols.size} symbols in ${indexed} files (${this.buildTime}ms)`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Index a single file
|
|
116
|
+
*/
|
|
117
|
+
indexFile(filePath) {
|
|
118
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
119
|
+
const hash = crypto.createHash('md5').update(content).digest('hex');
|
|
120
|
+
const stat = fs.statSync(filePath);
|
|
121
|
+
|
|
122
|
+
// Check if already indexed and unchanged
|
|
123
|
+
const existing = this.files.get(filePath);
|
|
124
|
+
if (existing && existing.hash === hash && existing.mtime === stat.mtimeMs) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (existing) {
|
|
129
|
+
this.removeFileSymbols(filePath);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const language = detectLanguage(filePath);
|
|
133
|
+
if (!language) return;
|
|
134
|
+
|
|
135
|
+
const parsed = parseFile(filePath);
|
|
136
|
+
const { imports } = extractImports(content, language);
|
|
137
|
+
const { exports } = extractExports(content, language);
|
|
138
|
+
|
|
139
|
+
const fileEntry = {
|
|
140
|
+
path: filePath,
|
|
141
|
+
relativePath: path.relative(this.root, filePath),
|
|
142
|
+
language,
|
|
143
|
+
lines: content.split('\n').length,
|
|
144
|
+
hash,
|
|
145
|
+
mtime: stat.mtimeMs,
|
|
146
|
+
size: stat.size,
|
|
147
|
+
imports: imports.map(i => i.module),
|
|
148
|
+
exports: exports.map(e => e.name),
|
|
149
|
+
symbols: []
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Add symbols
|
|
153
|
+
const addSymbol = (item, type) => {
|
|
154
|
+
const symbol = {
|
|
155
|
+
name: item.name,
|
|
156
|
+
type,
|
|
157
|
+
file: filePath,
|
|
158
|
+
relativePath: fileEntry.relativePath,
|
|
159
|
+
startLine: item.startLine,
|
|
160
|
+
endLine: item.endLine,
|
|
161
|
+
params: item.params,
|
|
162
|
+
paramsStructured: item.paramsStructured,
|
|
163
|
+
returnType: item.returnType,
|
|
164
|
+
modifiers: item.modifiers,
|
|
165
|
+
docstring: item.docstring,
|
|
166
|
+
...(item.extends && { extends: item.extends }),
|
|
167
|
+
...(item.implements && { implements: item.implements }),
|
|
168
|
+
...(item.indent !== undefined && { indent: item.indent }),
|
|
169
|
+
...(item.isNested && { isNested: item.isNested })
|
|
170
|
+
};
|
|
171
|
+
fileEntry.symbols.push(symbol);
|
|
172
|
+
|
|
173
|
+
if (!this.symbols.has(item.name)) {
|
|
174
|
+
this.symbols.set(item.name, []);
|
|
175
|
+
}
|
|
176
|
+
this.symbols.get(item.name).push(symbol);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
for (const fn of parsed.functions) {
|
|
180
|
+
addSymbol(fn, fn.isConstructor ? 'constructor' : 'function');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const cls of parsed.classes) {
|
|
184
|
+
addSymbol(cls, cls.type || 'class');
|
|
185
|
+
if (cls.members) {
|
|
186
|
+
for (const m of cls.members) {
|
|
187
|
+
const memberType = m.memberType || 'method';
|
|
188
|
+
addSymbol({ ...m, className: cls.name }, memberType);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const state of parsed.stateObjects) {
|
|
194
|
+
addSymbol(state, 'state');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.files.set(filePath, fileEntry);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Remove a file's symbols from the global map
|
|
202
|
+
*/
|
|
203
|
+
removeFileSymbols(filePath) {
|
|
204
|
+
const existing = this.files.get(filePath);
|
|
205
|
+
if (!existing) return;
|
|
206
|
+
|
|
207
|
+
for (const symbol of existing.symbols) {
|
|
208
|
+
const entries = this.symbols.get(symbol.name);
|
|
209
|
+
if (entries) {
|
|
210
|
+
const filtered = entries.filter(e => e.file !== filePath);
|
|
211
|
+
if (filtered.length > 0) {
|
|
212
|
+
this.symbols.set(symbol.name, filtered);
|
|
213
|
+
} else {
|
|
214
|
+
this.symbols.delete(symbol.name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build import/export relationship graphs
|
|
222
|
+
*/
|
|
223
|
+
buildImportGraph() {
|
|
224
|
+
this.importGraph.clear();
|
|
225
|
+
this.exportGraph.clear();
|
|
226
|
+
|
|
227
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
228
|
+
const importedFiles = [];
|
|
229
|
+
|
|
230
|
+
for (const importModule of fileEntry.imports) {
|
|
231
|
+
const resolved = resolveImport(importModule, filePath, {
|
|
232
|
+
aliases: this.config.aliases,
|
|
233
|
+
language: fileEntry.language,
|
|
234
|
+
root: this.root
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (resolved && this.files.has(resolved)) {
|
|
238
|
+
importedFiles.push(resolved);
|
|
239
|
+
|
|
240
|
+
if (!this.exportGraph.has(resolved)) {
|
|
241
|
+
this.exportGraph.set(resolved, []);
|
|
242
|
+
}
|
|
243
|
+
this.exportGraph.get(resolved).push(filePath);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.importGraph.set(filePath, importedFiles);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build inheritance relationship graphs
|
|
253
|
+
*/
|
|
254
|
+
buildInheritanceGraph() {
|
|
255
|
+
this.extendsGraph.clear();
|
|
256
|
+
this.extendedByGraph.clear();
|
|
257
|
+
|
|
258
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
259
|
+
for (const symbol of fileEntry.symbols) {
|
|
260
|
+
if (!['class', 'interface', 'struct', 'trait'].includes(symbol.type)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (symbol.extends) {
|
|
265
|
+
this.extendsGraph.set(symbol.name, symbol.extends);
|
|
266
|
+
|
|
267
|
+
if (!this.extendedByGraph.has(symbol.extends)) {
|
|
268
|
+
this.extendedByGraph.set(symbol.extends, []);
|
|
269
|
+
}
|
|
270
|
+
this.extendedByGraph.get(symbol.extends).push({
|
|
271
|
+
name: symbol.name,
|
|
272
|
+
type: symbol.type,
|
|
273
|
+
file: filePath
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ========================================================================
|
|
281
|
+
// QUERY METHODS
|
|
282
|
+
// ========================================================================
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check if a file path matches filter criteria
|
|
286
|
+
* @param {string} filePath - File path to check
|
|
287
|
+
* @param {object} filters - { exclude: string[], in: string }
|
|
288
|
+
* @returns {boolean} True if file passes filters
|
|
289
|
+
*/
|
|
290
|
+
matchesFilters(filePath, filters = {}) {
|
|
291
|
+
// Check exclusions (patterns like 'test', 'mock', 'spec')
|
|
292
|
+
if (filters.exclude && filters.exclude.length > 0) {
|
|
293
|
+
const lowerPath = filePath.toLowerCase();
|
|
294
|
+
for (const pattern of filters.exclude) {
|
|
295
|
+
if (lowerPath.includes(pattern.toLowerCase())) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Check inclusion (must be within specified directory)
|
|
302
|
+
if (filters.in) {
|
|
303
|
+
if (!filePath.includes(filters.in)) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Calculate fuzzy match score (higher = better match)
|
|
313
|
+
* Prefers: exact match > prefix match > camelCase match > substring match
|
|
314
|
+
*/
|
|
315
|
+
fuzzyScore(query, target) {
|
|
316
|
+
const lowerQuery = query.toLowerCase();
|
|
317
|
+
const lowerTarget = target.toLowerCase();
|
|
318
|
+
|
|
319
|
+
// Exact match
|
|
320
|
+
if (target === query) return 1000;
|
|
321
|
+
if (lowerTarget === lowerQuery) return 900;
|
|
322
|
+
|
|
323
|
+
// Prefix match (handleReq -> handleRequest)
|
|
324
|
+
if (lowerTarget.startsWith(lowerQuery)) return 800 + (query.length / target.length) * 100;
|
|
325
|
+
|
|
326
|
+
// CamelCase match (hR -> handleRequest)
|
|
327
|
+
const camelParts = target.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(' ');
|
|
328
|
+
const queryParts = query.toLowerCase();
|
|
329
|
+
let camelMatch = true;
|
|
330
|
+
let partIndex = 0;
|
|
331
|
+
for (const char of queryParts) {
|
|
332
|
+
while (partIndex < camelParts.length && !camelParts[partIndex].startsWith(char)) {
|
|
333
|
+
partIndex++;
|
|
334
|
+
}
|
|
335
|
+
if (partIndex >= camelParts.length) {
|
|
336
|
+
camelMatch = false;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
partIndex++;
|
|
340
|
+
}
|
|
341
|
+
if (camelMatch && query.length >= 2) return 600;
|
|
342
|
+
|
|
343
|
+
// Substring match
|
|
344
|
+
if (lowerTarget.includes(lowerQuery)) return 400 + (query.length / target.length) * 100;
|
|
345
|
+
|
|
346
|
+
// Word boundary match (parse -> parseFile, fileParse)
|
|
347
|
+
const words = lowerTarget.split(/(?=[A-Z])|_|-/);
|
|
348
|
+
if (words.some(w => w.startsWith(lowerQuery))) return 300;
|
|
349
|
+
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Find symbol by name with disambiguation
|
|
355
|
+
*
|
|
356
|
+
* @param {string} name - Symbol name to find
|
|
357
|
+
* @param {object} options - { file, prefer, exact, exclude, in }
|
|
358
|
+
* @returns {Array} Matching symbols with usage counts
|
|
359
|
+
*/
|
|
360
|
+
find(name, options = {}) {
|
|
361
|
+
const matches = this.symbols.get(name) || [];
|
|
362
|
+
|
|
363
|
+
if (matches.length === 0) {
|
|
364
|
+
// Smart fuzzy search with scoring
|
|
365
|
+
const candidates = [];
|
|
366
|
+
for (const [symName, symbols] of this.symbols) {
|
|
367
|
+
const score = this.fuzzyScore(name, symName);
|
|
368
|
+
if (score > 0) {
|
|
369
|
+
for (const sym of symbols) {
|
|
370
|
+
candidates.push({ ...sym, _fuzzyScore: score });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Sort by fuzzy score descending
|
|
375
|
+
candidates.sort((a, b) => b._fuzzyScore - a._fuzzyScore);
|
|
376
|
+
matches.push(...candidates);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Apply filters
|
|
380
|
+
let filtered = matches;
|
|
381
|
+
|
|
382
|
+
// Filter by file pattern
|
|
383
|
+
if (options.file) {
|
|
384
|
+
filtered = filtered.filter(m =>
|
|
385
|
+
m.relativePath && m.relativePath.includes(options.file)
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Apply semantic filters (--exclude, --in)
|
|
390
|
+
if (options.exclude || options.in) {
|
|
391
|
+
filtered = filtered.filter(m =>
|
|
392
|
+
this.matchesFilters(m.relativePath, { exclude: options.exclude, in: options.in })
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Add per-symbol usage counts for disambiguation
|
|
397
|
+
const withCounts = filtered.map(m => {
|
|
398
|
+
const counts = this.countSymbolUsages(m);
|
|
399
|
+
return {
|
|
400
|
+
...m,
|
|
401
|
+
usageCount: counts.total,
|
|
402
|
+
usageCounts: counts // { total, calls, definitions, imports, references }
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Sort by usage count (most-used first)
|
|
407
|
+
withCounts.sort((a, b) => b.usageCount - a.usageCount);
|
|
408
|
+
|
|
409
|
+
return withCounts;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Count usages of a symbol across the codebase
|
|
414
|
+
*/
|
|
415
|
+
countUsages(name) {
|
|
416
|
+
let count = 0;
|
|
417
|
+
const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b', 'g');
|
|
418
|
+
|
|
419
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
420
|
+
try {
|
|
421
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
422
|
+
const matches = content.match(regex);
|
|
423
|
+
if (matches) count += matches.length;
|
|
424
|
+
} catch (e) {
|
|
425
|
+
// Skip unreadable files
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return count;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Count usages of a specific symbol (not just by name)
|
|
434
|
+
* Only counts usages in files that could reference this specific definition
|
|
435
|
+
* @param {object} symbol - Symbol with file, name, etc.
|
|
436
|
+
* @returns {object} { total, calls, definitions, imports, references }
|
|
437
|
+
*/
|
|
438
|
+
countSymbolUsages(symbol) {
|
|
439
|
+
const name = symbol.name;
|
|
440
|
+
const defFile = symbol.file;
|
|
441
|
+
// Note: no 'g' flag - we only need to test for presence per line
|
|
442
|
+
const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
|
|
443
|
+
|
|
444
|
+
// Get files that could reference this symbol:
|
|
445
|
+
// 1. The file where it's defined
|
|
446
|
+
// 2. Files that import from the definition file
|
|
447
|
+
const relevantFiles = new Set([defFile]);
|
|
448
|
+
const importers = this.exportGraph.get(defFile) || [];
|
|
449
|
+
for (const importer of importers) {
|
|
450
|
+
relevantFiles.add(importer);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let calls = 0;
|
|
454
|
+
let definitions = 0;
|
|
455
|
+
let imports = 0;
|
|
456
|
+
let references = 0;
|
|
457
|
+
|
|
458
|
+
for (const filePath of relevantFiles) {
|
|
459
|
+
if (!this.files.has(filePath)) continue;
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
463
|
+
|
|
464
|
+
// Try AST-based counting first
|
|
465
|
+
const language = detectLanguage(filePath);
|
|
466
|
+
const langModule = getLanguageModule(language);
|
|
467
|
+
|
|
468
|
+
if (langModule && typeof langModule.findUsagesInCode === 'function') {
|
|
469
|
+
try {
|
|
470
|
+
const parser = getParser(language);
|
|
471
|
+
if (parser) {
|
|
472
|
+
const usages = langModule.findUsagesInCode(content, name, parser);
|
|
473
|
+
for (const u of usages) {
|
|
474
|
+
switch (u.usageType) {
|
|
475
|
+
case 'call': calls++; break;
|
|
476
|
+
case 'definition': definitions++; break;
|
|
477
|
+
case 'import': imports++; break;
|
|
478
|
+
default: references++; break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
continue; // Skip to next file
|
|
482
|
+
}
|
|
483
|
+
} catch (e) {
|
|
484
|
+
// Fall through to regex-based counting
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Fallback: count regex matches as references (unsupported language)
|
|
489
|
+
const lines = content.split('\n');
|
|
490
|
+
lines.forEach((line) => {
|
|
491
|
+
if (regex.test(line)) {
|
|
492
|
+
references++;
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
} catch (e) {
|
|
496
|
+
// Skip unreadable files
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
total: calls + definitions + imports + references,
|
|
502
|
+
calls,
|
|
503
|
+
definitions,
|
|
504
|
+
imports,
|
|
505
|
+
references
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Find all usages of a symbol grouped by type
|
|
511
|
+
*
|
|
512
|
+
* @param {string} name - Symbol name
|
|
513
|
+
* @param {object} options - { codeOnly, context, exclude, in }
|
|
514
|
+
* @returns {Array} Usages grouped as definitions, calls, imports, references
|
|
515
|
+
*/
|
|
516
|
+
usages(name, options = {}) {
|
|
517
|
+
const usages = [];
|
|
518
|
+
|
|
519
|
+
// Get definitions (filtered)
|
|
520
|
+
const allDefinitions = this.symbols.get(name) || [];
|
|
521
|
+
const definitions = options.exclude || options.in
|
|
522
|
+
? allDefinitions.filter(d => this.matchesFilters(d.relativePath, options))
|
|
523
|
+
: allDefinitions;
|
|
524
|
+
|
|
525
|
+
for (const def of definitions) {
|
|
526
|
+
usages.push({
|
|
527
|
+
...def,
|
|
528
|
+
isDefinition: true,
|
|
529
|
+
line: def.startLine,
|
|
530
|
+
content: this.getLineContent(def.file, def.startLine),
|
|
531
|
+
signature: this.formatSignature(def)
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Scan all files for usages
|
|
536
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
537
|
+
// Apply filters
|
|
538
|
+
if (!this.matchesFilters(fileEntry.relativePath, options)) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
544
|
+
const lines = content.split('\n');
|
|
545
|
+
|
|
546
|
+
// Try AST-based detection first
|
|
547
|
+
const lang = detectLanguage(filePath);
|
|
548
|
+
const langModule = getLanguageModule(lang);
|
|
549
|
+
|
|
550
|
+
if (langModule && typeof langModule.findUsagesInCode === 'function') {
|
|
551
|
+
// AST-based detection
|
|
552
|
+
try {
|
|
553
|
+
const parser = getParser(lang);
|
|
554
|
+
if (parser) {
|
|
555
|
+
const astUsages = langModule.findUsagesInCode(content, name, parser);
|
|
556
|
+
|
|
557
|
+
for (const u of astUsages) {
|
|
558
|
+
// Skip if this is a definition line (already added above)
|
|
559
|
+
if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const lineContent = lines[u.line - 1] || '';
|
|
564
|
+
|
|
565
|
+
const usage = {
|
|
566
|
+
file: filePath,
|
|
567
|
+
relativePath: fileEntry.relativePath,
|
|
568
|
+
line: u.line,
|
|
569
|
+
content: lineContent,
|
|
570
|
+
usageType: u.usageType,
|
|
571
|
+
isDefinition: false
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
// Add context lines if requested
|
|
575
|
+
if (options.context && options.context > 0) {
|
|
576
|
+
const idx = u.line - 1;
|
|
577
|
+
const before = [];
|
|
578
|
+
const after = [];
|
|
579
|
+
for (let i = 1; i <= options.context; i++) {
|
|
580
|
+
if (idx - i >= 0) before.unshift(lines[idx - i]);
|
|
581
|
+
if (idx + i < lines.length) after.push(lines[idx + i]);
|
|
582
|
+
}
|
|
583
|
+
usage.before = before;
|
|
584
|
+
usage.after = after;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
usages.push(usage);
|
|
588
|
+
}
|
|
589
|
+
continue; // Skip to next file
|
|
590
|
+
}
|
|
591
|
+
} catch (e) {
|
|
592
|
+
// Fall through to regex-based detection
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Fallback to regex-based detection
|
|
597
|
+
const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
|
|
598
|
+
lines.forEach((line, idx) => {
|
|
599
|
+
const lineNum = idx + 1;
|
|
600
|
+
|
|
601
|
+
// Skip if this is a definition line
|
|
602
|
+
if (definitions.some(d => d.file === filePath && d.startLine === lineNum)) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (regex.test(line)) {
|
|
607
|
+
// Skip if codeOnly and line is comment/string
|
|
608
|
+
if (options.codeOnly && this.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Skip if the match is inside a string literal
|
|
613
|
+
if (this.isInsideStringAST(content, lineNum, line, name, filePath)) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
|
|
618
|
+
const usageType = this.classifyUsageAST(content, lineNum, name, filePath) ?? 'reference';
|
|
619
|
+
|
|
620
|
+
const usage = {
|
|
621
|
+
file: filePath,
|
|
622
|
+
relativePath: fileEntry.relativePath,
|
|
623
|
+
line: lineNum,
|
|
624
|
+
content: line,
|
|
625
|
+
usageType,
|
|
626
|
+
isDefinition: false
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// Add context lines if requested
|
|
630
|
+
if (options.context && options.context > 0) {
|
|
631
|
+
const before = [];
|
|
632
|
+
const after = [];
|
|
633
|
+
for (let i = 1; i <= options.context; i++) {
|
|
634
|
+
if (idx - i >= 0) before.unshift(lines[idx - i]);
|
|
635
|
+
if (idx + i < lines.length) after.push(lines[idx + i]);
|
|
636
|
+
}
|
|
637
|
+
usage.before = before;
|
|
638
|
+
usage.after = after;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
usages.push(usage);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
} catch (e) {
|
|
645
|
+
// Skip unreadable files
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return usages;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Get context for a symbol (callers + callees)
|
|
654
|
+
*/
|
|
655
|
+
context(name, options = {}) {
|
|
656
|
+
const definitions = this.symbols.get(name) || [];
|
|
657
|
+
if (definitions.length === 0) {
|
|
658
|
+
return { function: name, file: null, callers: [], callees: [] };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const def = definitions[0]; // Use first definition
|
|
662
|
+
const callers = this.findCallers(name, { includeMethods: options.includeMethods });
|
|
663
|
+
const callees = this.findCallees(def, { includeMethods: options.includeMethods });
|
|
664
|
+
|
|
665
|
+
const result = {
|
|
666
|
+
function: name,
|
|
667
|
+
file: def.relativePath,
|
|
668
|
+
startLine: def.startLine,
|
|
669
|
+
endLine: def.endLine,
|
|
670
|
+
params: def.params,
|
|
671
|
+
returnType: def.returnType,
|
|
672
|
+
callers,
|
|
673
|
+
callees
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Add disambiguation warning if multiple definitions exist
|
|
677
|
+
if (definitions.length > 1) {
|
|
678
|
+
result.warnings = [{
|
|
679
|
+
type: 'ambiguous',
|
|
680
|
+
message: `Found ${definitions.length} definitions for "${name}". Using ${def.relativePath}:${def.startLine}. Use --file to disambiguate.`,
|
|
681
|
+
alternatives: definitions.slice(1).map(d => ({
|
|
682
|
+
file: d.relativePath,
|
|
683
|
+
line: d.startLine
|
|
684
|
+
}))
|
|
685
|
+
}];
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return result;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Get cached calls for a file, parsing if necessary
|
|
693
|
+
* Uses mtime for fast cache validation, falls back to hash if mtime matches but content changed
|
|
694
|
+
* @param {string} filePath - Path to the file
|
|
695
|
+
* @param {object} [options] - Options
|
|
696
|
+
* @param {boolean} [options.includeContent] - Also return file content (avoids double read)
|
|
697
|
+
* @returns {Array|null|{calls: Array, content: string}} Array of calls, or object with content if requested
|
|
698
|
+
*/
|
|
699
|
+
getCachedCalls(filePath, options = {}) {
|
|
700
|
+
try {
|
|
701
|
+
const cached = this.callsCache.get(filePath);
|
|
702
|
+
|
|
703
|
+
// Fast path: check mtime first (stat is much faster than read+hash)
|
|
704
|
+
const stat = fs.statSync(filePath);
|
|
705
|
+
const mtime = stat.mtimeMs;
|
|
706
|
+
|
|
707
|
+
if (cached && cached.mtime === mtime) {
|
|
708
|
+
// mtime matches - cache is likely valid
|
|
709
|
+
if (options.includeContent) {
|
|
710
|
+
// Need content, read if not cached
|
|
711
|
+
const content = cached.content || fs.readFileSync(filePath, 'utf-8');
|
|
712
|
+
return { calls: cached.calls, content };
|
|
713
|
+
}
|
|
714
|
+
return cached.calls;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// mtime changed or no cache - need to read and possibly reparse
|
|
718
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
719
|
+
const hash = crypto.createHash('md5').update(content).digest('hex');
|
|
720
|
+
|
|
721
|
+
// Check if content actually changed (mtime can change without content change)
|
|
722
|
+
if (cached && cached.hash === hash) {
|
|
723
|
+
// Content unchanged, just update mtime
|
|
724
|
+
cached.mtime = mtime;
|
|
725
|
+
cached.content = options.includeContent ? content : undefined;
|
|
726
|
+
if (options.includeContent) {
|
|
727
|
+
return { calls: cached.calls, content };
|
|
728
|
+
}
|
|
729
|
+
return cached.calls;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Content changed - need to reparse
|
|
733
|
+
const language = detectLanguage(filePath);
|
|
734
|
+
if (!language) return null;
|
|
735
|
+
|
|
736
|
+
const langModule = getLanguageModule(language);
|
|
737
|
+
if (!langModule.findCallsInCode) return null;
|
|
738
|
+
|
|
739
|
+
const parser = getParser(language);
|
|
740
|
+
const calls = langModule.findCallsInCode(content, parser);
|
|
741
|
+
|
|
742
|
+
this.callsCache.set(filePath, {
|
|
743
|
+
mtime,
|
|
744
|
+
hash,
|
|
745
|
+
calls,
|
|
746
|
+
content: options.includeContent ? content : undefined
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
if (options.includeContent) {
|
|
750
|
+
return { calls, content };
|
|
751
|
+
}
|
|
752
|
+
return calls;
|
|
753
|
+
} catch (e) {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Find all callers of a function using AST-based detection
|
|
760
|
+
* @param {string} name - Function name to find callers for
|
|
761
|
+
* @param {object} [options] - Options
|
|
762
|
+
* @param {boolean} [options.includeMethods] - Include method calls (default: false)
|
|
763
|
+
*/
|
|
764
|
+
findCallers(name, options = {}) {
|
|
765
|
+
const callers = [];
|
|
766
|
+
|
|
767
|
+
// Get definition lines to exclude them
|
|
768
|
+
const definitions = this.symbols.get(name) || [];
|
|
769
|
+
const definitionLines = new Set();
|
|
770
|
+
for (const def of definitions) {
|
|
771
|
+
definitionLines.add(`${def.file}:${def.startLine}`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
775
|
+
try {
|
|
776
|
+
const result = this.getCachedCalls(filePath, { includeContent: true });
|
|
777
|
+
if (!result) continue;
|
|
778
|
+
|
|
779
|
+
const { calls, content } = result;
|
|
780
|
+
const lines = content.split('\n');
|
|
781
|
+
|
|
782
|
+
for (const call of calls) {
|
|
783
|
+
// Skip if not matching our target name
|
|
784
|
+
if (call.name !== name) continue;
|
|
785
|
+
|
|
786
|
+
// Smart method call handling
|
|
787
|
+
if (call.isMethod) {
|
|
788
|
+
// Always skip this/self/cls calls (internal state access, not function calls)
|
|
789
|
+
if (['this', 'self', 'cls'].includes(call.receiver)) continue;
|
|
790
|
+
// Skip other method calls unless explicitly requested
|
|
791
|
+
if (!options.includeMethods) continue;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Skip definition lines
|
|
795
|
+
if (definitionLines.has(`${filePath}:${call.line}`)) continue;
|
|
796
|
+
|
|
797
|
+
// Find the enclosing function (get full symbol info)
|
|
798
|
+
const callerSymbol = this.findEnclosingFunction(filePath, call.line, true);
|
|
799
|
+
|
|
800
|
+
callers.push({
|
|
801
|
+
file: filePath,
|
|
802
|
+
relativePath: fileEntry.relativePath,
|
|
803
|
+
line: call.line,
|
|
804
|
+
content: lines[call.line - 1] || '',
|
|
805
|
+
callerName: callerSymbol ? callerSymbol.name : null,
|
|
806
|
+
callerFile: callerSymbol ? filePath : null,
|
|
807
|
+
callerStartLine: callerSymbol ? callerSymbol.startLine : null,
|
|
808
|
+
callerEndLine: callerSymbol ? callerSymbol.endLine : null,
|
|
809
|
+
isMethod: call.isMethod || false,
|
|
810
|
+
receiver: call.receiver
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
} catch (e) {
|
|
814
|
+
// Skip files that can't be processed
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return callers;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Check if a name appears inside a string literal using AST
|
|
823
|
+
* @param {string} content - Full file content
|
|
824
|
+
* @param {number} lineNum - 1-indexed line number
|
|
825
|
+
* @param {string} line - Line content
|
|
826
|
+
* @param {string} name - Name to check
|
|
827
|
+
* @param {string} filePath - File path for language detection
|
|
828
|
+
* @returns {boolean} true if ALL occurrences of name are inside strings
|
|
829
|
+
*/
|
|
830
|
+
isInsideStringAST(content, lineNum, line, name, filePath) {
|
|
831
|
+
const language = detectLanguage(filePath);
|
|
832
|
+
if (!language) {
|
|
833
|
+
return false; // Unsupported language - assume not inside string
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
try {
|
|
837
|
+
const parser = getParser(language);
|
|
838
|
+
if (!parser) {
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const tree = parser.parse(content, undefined, PARSE_OPTIONS);
|
|
843
|
+
|
|
844
|
+
// Find all occurrences of name in the line
|
|
845
|
+
const nameRegex = new RegExp('(?<![a-zA-Z0-9_$])' + escapeRegExp(name) + '(?![a-zA-Z0-9_$])', 'g');
|
|
846
|
+
let match;
|
|
847
|
+
|
|
848
|
+
while ((match = nameRegex.exec(line)) !== null) {
|
|
849
|
+
const column = match.index;
|
|
850
|
+
const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
|
|
851
|
+
|
|
852
|
+
// If this occurrence is NOT in a string, the name appears in code
|
|
853
|
+
if (tokenType !== 'string') {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// All occurrences were inside strings (or no occurrences found)
|
|
859
|
+
return true;
|
|
860
|
+
} catch (e) {
|
|
861
|
+
return false; // On error, assume not inside string
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Find all functions called by a function using AST-based detection
|
|
867
|
+
* @param {object} def - Symbol definition with file, name, startLine, endLine
|
|
868
|
+
* @param {object} [options] - Options
|
|
869
|
+
* @param {boolean} [options.includeMethods] - Include method calls (default: false)
|
|
870
|
+
*/
|
|
871
|
+
findCallees(def, options = {}) {
|
|
872
|
+
try {
|
|
873
|
+
// Get all calls from the file's cache (now includes enclosingFunction)
|
|
874
|
+
const calls = this.getCachedCalls(def.file);
|
|
875
|
+
if (!calls) return [];
|
|
876
|
+
|
|
877
|
+
const callees = new Map(); // name -> count
|
|
878
|
+
|
|
879
|
+
for (const call of calls) {
|
|
880
|
+
// Filter to calls within this function's scope using enclosingFunction
|
|
881
|
+
if (!call.enclosingFunction) continue;
|
|
882
|
+
if (call.enclosingFunction.name !== def.name) continue;
|
|
883
|
+
if (call.enclosingFunction.startLine !== def.startLine) continue;
|
|
884
|
+
|
|
885
|
+
// Skip method calls unless explicitly requested
|
|
886
|
+
if (call.isMethod && !options.includeMethods) continue;
|
|
887
|
+
|
|
888
|
+
// Skip keywords and built-ins
|
|
889
|
+
if (this.isKeyword(call.name)) continue;
|
|
890
|
+
|
|
891
|
+
callees.set(call.name, (callees.get(call.name) || 0) + 1);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Look up each callee in the symbol table
|
|
895
|
+
const result = [];
|
|
896
|
+
for (const [calleeName, count] of callees) {
|
|
897
|
+
const symbols = this.symbols.get(calleeName);
|
|
898
|
+
if (symbols && symbols.length > 0) {
|
|
899
|
+
const callee = symbols[0];
|
|
900
|
+
result.push({
|
|
901
|
+
...callee,
|
|
902
|
+
callCount: count,
|
|
903
|
+
weight: this.calculateWeight(count)
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Sort by call count (core dependencies first)
|
|
909
|
+
result.sort((a, b) => b.callCount - a.callCount);
|
|
910
|
+
|
|
911
|
+
return result;
|
|
912
|
+
} catch (e) {
|
|
913
|
+
return [];
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Calculate dependency weight based on usage
|
|
919
|
+
*/
|
|
920
|
+
calculateWeight(callCount) {
|
|
921
|
+
if (callCount >= 10) return 'core';
|
|
922
|
+
if (callCount >= 3) return 'regular';
|
|
923
|
+
if (callCount === 1) return 'utility';
|
|
924
|
+
return 'normal';
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Smart extraction: function + dependencies
|
|
929
|
+
*/
|
|
930
|
+
smart(name, options = {}) {
|
|
931
|
+
const definitions = this.symbols.get(name) || [];
|
|
932
|
+
if (definitions.length === 0) {
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const def = definitions[0];
|
|
937
|
+
const code = this.extractCode(def);
|
|
938
|
+
const callees = this.findCallees(def, { includeMethods: options.includeMethods });
|
|
939
|
+
|
|
940
|
+
// Extract code for each dependency, excluding the main function itself
|
|
941
|
+
const dependencies = callees
|
|
942
|
+
.filter(callee => callee.name !== name) // Don't include self
|
|
943
|
+
.map(callee => ({
|
|
944
|
+
...callee,
|
|
945
|
+
code: this.extractCode(callee)
|
|
946
|
+
}));
|
|
947
|
+
|
|
948
|
+
// Find type definitions if requested
|
|
949
|
+
const types = [];
|
|
950
|
+
if (options.withTypes) {
|
|
951
|
+
// Look for type annotations in params/return type
|
|
952
|
+
const typeNames = this.extractTypeNames(def);
|
|
953
|
+
for (const typeName of typeNames) {
|
|
954
|
+
const typeSymbols = this.symbols.get(typeName);
|
|
955
|
+
if (typeSymbols) {
|
|
956
|
+
for (const sym of typeSymbols) {
|
|
957
|
+
if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
|
|
958
|
+
types.push({
|
|
959
|
+
...sym,
|
|
960
|
+
code: this.extractCode(sym)
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
target: {
|
|
970
|
+
...def,
|
|
971
|
+
code
|
|
972
|
+
},
|
|
973
|
+
dependencies,
|
|
974
|
+
types
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ========================================================================
|
|
979
|
+
// HELPER METHODS
|
|
980
|
+
// ========================================================================
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Get line content from a file
|
|
984
|
+
*/
|
|
985
|
+
getLineContent(filePath, lineNum) {
|
|
986
|
+
try {
|
|
987
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
988
|
+
const lines = content.split('\n');
|
|
989
|
+
return lines[lineNum - 1] || '';
|
|
990
|
+
} catch (e) {
|
|
991
|
+
return '';
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Extract code for a symbol
|
|
997
|
+
*/
|
|
998
|
+
extractCode(symbol) {
|
|
999
|
+
try {
|
|
1000
|
+
const content = fs.readFileSync(symbol.file, 'utf-8');
|
|
1001
|
+
const lines = content.split('\n');
|
|
1002
|
+
return lines.slice(symbol.startLine - 1, symbol.endLine).join('\n');
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
return '';
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Format a signature for display
|
|
1010
|
+
*/
|
|
1011
|
+
formatSignature(def) {
|
|
1012
|
+
const parts = [];
|
|
1013
|
+
if (def.modifiers && def.modifiers.length) {
|
|
1014
|
+
parts.push(def.modifiers.join(' '));
|
|
1015
|
+
}
|
|
1016
|
+
parts.push(def.name);
|
|
1017
|
+
if (def.params !== undefined) {
|
|
1018
|
+
parts.push(`(${def.params})`);
|
|
1019
|
+
}
|
|
1020
|
+
if (def.returnType) {
|
|
1021
|
+
parts.push(`: ${def.returnType}`);
|
|
1022
|
+
}
|
|
1023
|
+
return parts.join(' ');
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Classify a usage as call, import, definition, or reference using AST
|
|
1028
|
+
* @param {string} content - File content
|
|
1029
|
+
* @param {number} lineNum - 1-indexed line number
|
|
1030
|
+
* @param {string} name - Symbol name
|
|
1031
|
+
* @param {string} filePath - File path for language detection
|
|
1032
|
+
* @returns {string} 'call', 'import', 'definition', or 'reference'
|
|
1033
|
+
*/
|
|
1034
|
+
classifyUsageAST(content, lineNum, name, filePath) {
|
|
1035
|
+
const language = detectLanguage(filePath);
|
|
1036
|
+
if (!language) {
|
|
1037
|
+
return null; // Signal to use fallback
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const langModule = getLanguageModule(language);
|
|
1041
|
+
if (!langModule || typeof langModule.findUsagesInCode !== 'function') {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
try {
|
|
1046
|
+
const parser = getParser(language);
|
|
1047
|
+
if (!parser) {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const usages = langModule.findUsagesInCode(content, name, parser);
|
|
1052
|
+
|
|
1053
|
+
// Find usage at this line
|
|
1054
|
+
const usage = usages.find(u => u.line === lineNum);
|
|
1055
|
+
if (usage) {
|
|
1056
|
+
return usage.usageType;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return 'reference'; // Default if not found
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Check if a position in code is inside a comment or string using AST
|
|
1067
|
+
* @param {string} content - File content
|
|
1068
|
+
* @param {number} lineNum - 1-indexed line number
|
|
1069
|
+
* @param {number} column - 0-indexed column
|
|
1070
|
+
* @param {string} filePath - File path (for language detection)
|
|
1071
|
+
* @returns {boolean}
|
|
1072
|
+
*/
|
|
1073
|
+
isCommentOrStringAtPosition(content, lineNum, column, filePath) {
|
|
1074
|
+
const language = detectLanguage(filePath);
|
|
1075
|
+
if (!language) {
|
|
1076
|
+
return false; // Can't determine, assume code
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
try {
|
|
1080
|
+
const parser = getParser(language);
|
|
1081
|
+
if (!parser) {
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const tree = parser.parse(content, undefined, PARSE_OPTIONS);
|
|
1086
|
+
const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
|
|
1087
|
+
return tokenType === 'comment' || tokenType === 'string';
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
return false; // On error, assume code
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Check if a name is a language keyword
|
|
1095
|
+
*/
|
|
1096
|
+
isKeyword(name) {
|
|
1097
|
+
const keywords = new Set([
|
|
1098
|
+
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break',
|
|
1099
|
+
'continue', 'return', 'function', 'class', 'const', 'let', 'var',
|
|
1100
|
+
'new', 'this', 'super', 'import', 'export', 'default', 'from',
|
|
1101
|
+
'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield',
|
|
1102
|
+
'typeof', 'instanceof', 'in', 'of', 'delete', 'void', 'with',
|
|
1103
|
+
'def', 'print', 'range', 'len', 'str', 'int', 'float', 'list',
|
|
1104
|
+
'dict', 'set', 'tuple', 'True', 'False', 'None', 'self', 'cls',
|
|
1105
|
+
'func', 'type', 'struct', 'interface', 'package', 'make', 'append',
|
|
1106
|
+
'fn', 'impl', 'pub', 'mod', 'use', 'crate', 'self', 'super',
|
|
1107
|
+
'match', 'loop', 'unsafe', 'move', 'ref', 'mut', 'where'
|
|
1108
|
+
]);
|
|
1109
|
+
return keywords.has(name);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Find the enclosing function at a line
|
|
1114
|
+
* @param {string} filePath - File path
|
|
1115
|
+
* @param {number} lineNum - Line number
|
|
1116
|
+
* @param {boolean} returnSymbol - If true, return full symbol info instead of just name
|
|
1117
|
+
* @returns {string|object|null} Function name, symbol object, or null
|
|
1118
|
+
*/
|
|
1119
|
+
findEnclosingFunction(filePath, lineNum, returnSymbol = false) {
|
|
1120
|
+
const fileEntry = this.files.get(filePath);
|
|
1121
|
+
if (!fileEntry) return null;
|
|
1122
|
+
|
|
1123
|
+
for (const symbol of fileEntry.symbols) {
|
|
1124
|
+
if (symbol.type === 'function' &&
|
|
1125
|
+
symbol.startLine <= lineNum &&
|
|
1126
|
+
symbol.endLine >= lineNum) {
|
|
1127
|
+
if (returnSymbol) {
|
|
1128
|
+
return symbol;
|
|
1129
|
+
}
|
|
1130
|
+
return symbol.name;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Extract type names from a function definition
|
|
1138
|
+
*/
|
|
1139
|
+
extractTypeNames(def) {
|
|
1140
|
+
const types = new Set();
|
|
1141
|
+
|
|
1142
|
+
// From params
|
|
1143
|
+
if (def.paramsStructured) {
|
|
1144
|
+
for (const param of def.paramsStructured) {
|
|
1145
|
+
if (param.type) {
|
|
1146
|
+
// Extract base type name (before < or [)
|
|
1147
|
+
const match = param.type.match(/^([A-Z]\w*)/);
|
|
1148
|
+
if (match) types.add(match[1]);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// From return type
|
|
1154
|
+
if (def.returnType) {
|
|
1155
|
+
const match = def.returnType.match(/^([A-Z]\w*)/);
|
|
1156
|
+
if (match) types.add(match[1]);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
return types;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// ========================================================================
|
|
1163
|
+
// NEW COMMANDS (v2 Migration)
|
|
1164
|
+
// ========================================================================
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Get imports for a file
|
|
1168
|
+
* @param {string} filePath - File to get imports for
|
|
1169
|
+
* @returns {Array} Imports with resolved paths
|
|
1170
|
+
*/
|
|
1171
|
+
imports(filePath) {
|
|
1172
|
+
const normalizedPath = path.isAbsolute(filePath)
|
|
1173
|
+
? filePath
|
|
1174
|
+
: path.join(this.root, filePath);
|
|
1175
|
+
|
|
1176
|
+
const fileEntry = this.files.get(normalizedPath);
|
|
1177
|
+
if (!fileEntry) {
|
|
1178
|
+
// Try to find by relative path
|
|
1179
|
+
for (const [absPath, entry] of this.files) {
|
|
1180
|
+
if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
|
|
1181
|
+
return this.imports(absPath);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return [];
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
try {
|
|
1188
|
+
const content = fs.readFileSync(normalizedPath, 'utf-8');
|
|
1189
|
+
const { imports: rawImports } = extractImports(content, fileEntry.language);
|
|
1190
|
+
|
|
1191
|
+
return rawImports.map(imp => {
|
|
1192
|
+
const resolved = resolveImport(imp.module, normalizedPath, {
|
|
1193
|
+
aliases: this.config.aliases,
|
|
1194
|
+
language: fileEntry.language,
|
|
1195
|
+
root: this.root
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Find line number of import
|
|
1199
|
+
const lines = content.split('\n');
|
|
1200
|
+
let line = null;
|
|
1201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1202
|
+
if (lines[i].includes(imp.module)) {
|
|
1203
|
+
line = i + 1;
|
|
1204
|
+
break;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
return {
|
|
1209
|
+
module: imp.module,
|
|
1210
|
+
names: imp.names,
|
|
1211
|
+
type: imp.type,
|
|
1212
|
+
resolved: resolved ? path.relative(this.root, resolved) : null,
|
|
1213
|
+
isExternal: !resolved,
|
|
1214
|
+
line
|
|
1215
|
+
};
|
|
1216
|
+
});
|
|
1217
|
+
} catch (e) {
|
|
1218
|
+
return [];
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Get files that import a given file
|
|
1224
|
+
* @param {string} filePath - File to check
|
|
1225
|
+
* @returns {Array} Files that import this file
|
|
1226
|
+
*/
|
|
1227
|
+
exporters(filePath) {
|
|
1228
|
+
const normalizedPath = path.isAbsolute(filePath)
|
|
1229
|
+
? filePath
|
|
1230
|
+
: path.join(this.root, filePath);
|
|
1231
|
+
|
|
1232
|
+
// Try to find the file
|
|
1233
|
+
let targetPath = normalizedPath;
|
|
1234
|
+
if (!this.files.has(normalizedPath)) {
|
|
1235
|
+
for (const [absPath, entry] of this.files) {
|
|
1236
|
+
if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
|
|
1237
|
+
targetPath = absPath;
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const importers = this.exportGraph.get(targetPath) || [];
|
|
1244
|
+
|
|
1245
|
+
return importers.map(importerPath => {
|
|
1246
|
+
const fileEntry = this.files.get(importerPath);
|
|
1247
|
+
|
|
1248
|
+
// Find the import line
|
|
1249
|
+
let importLine = null;
|
|
1250
|
+
try {
|
|
1251
|
+
const content = fs.readFileSync(importerPath, 'utf-8');
|
|
1252
|
+
const lines = content.split('\n');
|
|
1253
|
+
const targetRelative = path.relative(this.root, targetPath);
|
|
1254
|
+
const targetBasename = path.basename(targetPath, path.extname(targetPath));
|
|
1255
|
+
|
|
1256
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1257
|
+
if (lines[i].includes(targetBasename) &&
|
|
1258
|
+
(lines[i].includes('import') || lines[i].includes('require') || lines[i].includes('from'))) {
|
|
1259
|
+
importLine = i + 1;
|
|
1260
|
+
break;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
} catch (e) {
|
|
1264
|
+
// Skip
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
return {
|
|
1268
|
+
file: fileEntry ? fileEntry.relativePath : path.relative(this.root, importerPath),
|
|
1269
|
+
importLine
|
|
1270
|
+
};
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Find type definitions
|
|
1276
|
+
* @param {string} name - Type name to find
|
|
1277
|
+
* @returns {Array} Matching type definitions
|
|
1278
|
+
*/
|
|
1279
|
+
typedef(name) {
|
|
1280
|
+
const typeKinds = ['type', 'interface', 'enum', 'struct', 'trait', 'class'];
|
|
1281
|
+
const matches = this.find(name);
|
|
1282
|
+
|
|
1283
|
+
return matches.filter(m => typeKinds.includes(m.type));
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Find tests for a function or file
|
|
1288
|
+
* @param {string} nameOrFile - Function name or file path
|
|
1289
|
+
* @returns {Array} Test files and matches
|
|
1290
|
+
*/
|
|
1291
|
+
tests(nameOrFile) {
|
|
1292
|
+
const results = [];
|
|
1293
|
+
|
|
1294
|
+
// Check if it's a file path
|
|
1295
|
+
const isFilePath = nameOrFile.includes('/') || nameOrFile.includes('\\') ||
|
|
1296
|
+
nameOrFile.endsWith('.js') || nameOrFile.endsWith('.ts') ||
|
|
1297
|
+
nameOrFile.endsWith('.py') || nameOrFile.endsWith('.go') ||
|
|
1298
|
+
nameOrFile.endsWith('.java') || nameOrFile.endsWith('.rs');
|
|
1299
|
+
|
|
1300
|
+
// Find all test files
|
|
1301
|
+
const testFiles = [];
|
|
1302
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
1303
|
+
if (isTestFile(filePath, fileEntry.language)) {
|
|
1304
|
+
testFiles.push({ path: filePath, entry: fileEntry });
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const searchTerm = isFilePath
|
|
1309
|
+
? path.basename(nameOrFile, path.extname(nameOrFile))
|
|
1310
|
+
: nameOrFile;
|
|
1311
|
+
|
|
1312
|
+
// Note: no 'g' flag - we only need to test for presence per line
|
|
1313
|
+
// The 'i' flag is kept for case-insensitive matching
|
|
1314
|
+
const regex = new RegExp('\\b' + escapeRegExp(searchTerm) + '\\b', 'i');
|
|
1315
|
+
|
|
1316
|
+
for (const { path: testPath, entry } of testFiles) {
|
|
1317
|
+
try {
|
|
1318
|
+
const content = fs.readFileSync(testPath, 'utf-8');
|
|
1319
|
+
const lines = content.split('\n');
|
|
1320
|
+
const matches = [];
|
|
1321
|
+
|
|
1322
|
+
lines.forEach((line, idx) => {
|
|
1323
|
+
if (regex.test(line)) {
|
|
1324
|
+
let matchType = 'reference';
|
|
1325
|
+
if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
|
|
1326
|
+
matchType = 'test-case';
|
|
1327
|
+
} else if (/\b(import|require|from)\b/.test(line)) {
|
|
1328
|
+
matchType = 'import';
|
|
1329
|
+
} else if (new RegExp(searchTerm + '\\s*\\(').test(line)) {
|
|
1330
|
+
matchType = 'call';
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
matches.push({
|
|
1334
|
+
line: idx + 1,
|
|
1335
|
+
content: line.trim(),
|
|
1336
|
+
matchType
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
if (matches.length > 0) {
|
|
1342
|
+
results.push({
|
|
1343
|
+
file: entry.relativePath,
|
|
1344
|
+
matches
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
} catch (e) {
|
|
1348
|
+
// Skip unreadable files
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
return results;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Get all exported/public symbols
|
|
1357
|
+
* @param {string} [filePath] - Optional file to limit to
|
|
1358
|
+
* @returns {Array} Exported symbols
|
|
1359
|
+
*/
|
|
1360
|
+
api(filePath) {
|
|
1361
|
+
const results = [];
|
|
1362
|
+
|
|
1363
|
+
const filesToCheck = filePath
|
|
1364
|
+
? [this.findFile(filePath)].filter(Boolean)
|
|
1365
|
+
: Array.from(this.files.entries());
|
|
1366
|
+
|
|
1367
|
+
for (const [absPath, fileEntry] of (filePath ? [[this.findFile(filePath), this.files.get(this.findFile(filePath))]] : this.files.entries())) {
|
|
1368
|
+
if (!fileEntry) continue;
|
|
1369
|
+
|
|
1370
|
+
const exportedNames = new Set(fileEntry.exports);
|
|
1371
|
+
|
|
1372
|
+
for (const symbol of fileEntry.symbols) {
|
|
1373
|
+
const isExported = exportedNames.has(symbol.name) ||
|
|
1374
|
+
(symbol.modifiers && symbol.modifiers.includes('export')) ||
|
|
1375
|
+
(symbol.modifiers && symbol.modifiers.includes('public')) ||
|
|
1376
|
+
(fileEntry.language === 'go' && /^[A-Z]/.test(symbol.name));
|
|
1377
|
+
|
|
1378
|
+
if (isExported) {
|
|
1379
|
+
results.push({
|
|
1380
|
+
name: symbol.name,
|
|
1381
|
+
type: symbol.type,
|
|
1382
|
+
file: fileEntry.relativePath,
|
|
1383
|
+
startLine: symbol.startLine,
|
|
1384
|
+
endLine: symbol.endLine,
|
|
1385
|
+
params: symbol.params,
|
|
1386
|
+
returnType: symbol.returnType,
|
|
1387
|
+
signature: this.formatSignature(symbol)
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
return results;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Find a file by path (supports partial paths)
|
|
1398
|
+
*/
|
|
1399
|
+
findFile(filePath) {
|
|
1400
|
+
const normalizedPath = path.isAbsolute(filePath)
|
|
1401
|
+
? filePath
|
|
1402
|
+
: path.join(this.root, filePath);
|
|
1403
|
+
|
|
1404
|
+
if (this.files.has(normalizedPath)) {
|
|
1405
|
+
return normalizedPath;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Try partial match
|
|
1409
|
+
for (const [absPath, entry] of this.files) {
|
|
1410
|
+
if (entry.relativePath === filePath || absPath.endsWith(filePath)) {
|
|
1411
|
+
return absPath;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Get exports for a specific file
|
|
1420
|
+
* @param {string} filePath - File path
|
|
1421
|
+
* @returns {Array} Exported symbols from that file
|
|
1422
|
+
*/
|
|
1423
|
+
fileExports(filePath) {
|
|
1424
|
+
const absPath = this.findFile(filePath);
|
|
1425
|
+
if (!absPath) {
|
|
1426
|
+
return [];
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const fileEntry = this.files.get(absPath);
|
|
1430
|
+
if (!fileEntry) {
|
|
1431
|
+
return [];
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const results = [];
|
|
1435
|
+
const exportedNames = new Set(fileEntry.exports);
|
|
1436
|
+
|
|
1437
|
+
for (const symbol of fileEntry.symbols) {
|
|
1438
|
+
const isExported = exportedNames.has(symbol.name) ||
|
|
1439
|
+
(symbol.modifiers && symbol.modifiers.includes('export')) ||
|
|
1440
|
+
(symbol.modifiers && symbol.modifiers.includes('public')) ||
|
|
1441
|
+
(fileEntry.language === 'go' && /^[A-Z]/.test(symbol.name));
|
|
1442
|
+
|
|
1443
|
+
if (isExported) {
|
|
1444
|
+
results.push({
|
|
1445
|
+
name: symbol.name,
|
|
1446
|
+
type: symbol.type,
|
|
1447
|
+
file: fileEntry.relativePath,
|
|
1448
|
+
startLine: symbol.startLine,
|
|
1449
|
+
endLine: symbol.endLine,
|
|
1450
|
+
params: symbol.params,
|
|
1451
|
+
returnType: symbol.returnType,
|
|
1452
|
+
signature: this.formatSignature(symbol)
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
return results;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Check if a function is used as a callback anywhere in the codebase
|
|
1462
|
+
* @param {string} name - Function name
|
|
1463
|
+
* @returns {Array} Callback usages
|
|
1464
|
+
*/
|
|
1465
|
+
findCallbackUsages(name) {
|
|
1466
|
+
const usages = [];
|
|
1467
|
+
|
|
1468
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
1469
|
+
try {
|
|
1470
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1471
|
+
const language = detectLanguage(filePath);
|
|
1472
|
+
if (!language) continue;
|
|
1473
|
+
|
|
1474
|
+
const langModule = getLanguageModule(language);
|
|
1475
|
+
if (!langModule.findCallbackUsages) continue;
|
|
1476
|
+
|
|
1477
|
+
const parser = getParser(language);
|
|
1478
|
+
const callbacks = langModule.findCallbackUsages(content, name, parser);
|
|
1479
|
+
|
|
1480
|
+
for (const cb of callbacks) {
|
|
1481
|
+
usages.push({
|
|
1482
|
+
file: filePath,
|
|
1483
|
+
relativePath: fileEntry.relativePath,
|
|
1484
|
+
...cb
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
} catch (e) {
|
|
1488
|
+
// Skip files that can't be processed
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
return usages;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Find re-exports of a symbol across the codebase
|
|
1497
|
+
* @param {string} name - Symbol name
|
|
1498
|
+
* @returns {Array} Re-export locations
|
|
1499
|
+
*/
|
|
1500
|
+
findReExportsOf(name) {
|
|
1501
|
+
const reExports = [];
|
|
1502
|
+
|
|
1503
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
1504
|
+
try {
|
|
1505
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1506
|
+
const language = detectLanguage(filePath);
|
|
1507
|
+
if (!language) continue;
|
|
1508
|
+
|
|
1509
|
+
const langModule = getLanguageModule(language);
|
|
1510
|
+
if (!langModule.findReExports) continue;
|
|
1511
|
+
|
|
1512
|
+
const parser = getParser(language);
|
|
1513
|
+
const exports = langModule.findReExports(content, parser);
|
|
1514
|
+
|
|
1515
|
+
for (const exp of exports) {
|
|
1516
|
+
if (exp.name === name) {
|
|
1517
|
+
reExports.push({
|
|
1518
|
+
file: filePath,
|
|
1519
|
+
relativePath: fileEntry.relativePath,
|
|
1520
|
+
...exp
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
} catch (e) {
|
|
1525
|
+
// Skip files that can't be processed
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
return reExports;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Build a usage index for all identifiers in the codebase (optimized for deadcode)
|
|
1534
|
+
* Scans all files ONCE and builds a reverse index: name -> [usages]
|
|
1535
|
+
* @returns {Map<string, Array>} Usage index
|
|
1536
|
+
*/
|
|
1537
|
+
buildUsageIndex() {
|
|
1538
|
+
const usageIndex = new Map(); // name -> [{file, line}]
|
|
1539
|
+
|
|
1540
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
1541
|
+
try {
|
|
1542
|
+
const language = detectLanguage(filePath);
|
|
1543
|
+
if (!language) continue;
|
|
1544
|
+
|
|
1545
|
+
const parser = getParser(language);
|
|
1546
|
+
if (!parser) continue;
|
|
1547
|
+
|
|
1548
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1549
|
+
const tree = parser.parse(content, undefined, PARSE_OPTIONS);
|
|
1550
|
+
|
|
1551
|
+
// Collect all identifiers from this file in one pass
|
|
1552
|
+
const traverse = (node) => {
|
|
1553
|
+
// Match all identifier-like nodes across languages
|
|
1554
|
+
if (node.type === 'identifier' ||
|
|
1555
|
+
node.type === 'property_identifier' ||
|
|
1556
|
+
node.type === 'type_identifier' ||
|
|
1557
|
+
node.type === 'shorthand_property_identifier' ||
|
|
1558
|
+
node.type === 'shorthand_property_identifier_pattern' ||
|
|
1559
|
+
node.type === 'field_identifier') {
|
|
1560
|
+
const name = node.text;
|
|
1561
|
+
if (!usageIndex.has(name)) {
|
|
1562
|
+
usageIndex.set(name, []);
|
|
1563
|
+
}
|
|
1564
|
+
usageIndex.get(name).push({
|
|
1565
|
+
file: filePath,
|
|
1566
|
+
line: node.startPosition.row + 1,
|
|
1567
|
+
relativePath: fileEntry.relativePath
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
1571
|
+
traverse(node.child(i));
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
traverse(tree.rootNode);
|
|
1575
|
+
} catch (e) {
|
|
1576
|
+
// Skip files that can't be processed
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
return usageIndex;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Find dead code (unused functions/classes)
|
|
1585
|
+
* @param {object} options - { includeExported, includeTests }
|
|
1586
|
+
* @returns {Array} Unused symbols
|
|
1587
|
+
*/
|
|
1588
|
+
deadcode(options = {}) {
|
|
1589
|
+
const results = [];
|
|
1590
|
+
|
|
1591
|
+
// Build usage index once (instead of per-symbol)
|
|
1592
|
+
const usageIndex = this.buildUsageIndex();
|
|
1593
|
+
|
|
1594
|
+
for (const [name, symbols] of this.symbols) {
|
|
1595
|
+
for (const symbol of symbols) {
|
|
1596
|
+
// Skip non-function/class types
|
|
1597
|
+
if (!['function', 'class', 'method'].includes(symbol.type)) {
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Skip test files unless requested
|
|
1602
|
+
if (!options.includeTests && isTestFile(symbol.file, symbol.language)) {
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Check if exported
|
|
1607
|
+
const fileEntry = this.files.get(symbol.file);
|
|
1608
|
+
const isExported = fileEntry && (
|
|
1609
|
+
fileEntry.exports.includes(name) ||
|
|
1610
|
+
(symbol.modifiers && symbol.modifiers.includes('export')) ||
|
|
1611
|
+
(symbol.modifiers && symbol.modifiers.includes('public')) ||
|
|
1612
|
+
(fileEntry.language === 'go' && /^[A-Z]/.test(name))
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
// Skip exported unless requested
|
|
1616
|
+
if (isExported && !options.includeExported) {
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Use pre-built index for O(1) lookup instead of O(files) scan
|
|
1621
|
+
const allUsages = usageIndex.get(name) || [];
|
|
1622
|
+
|
|
1623
|
+
// Filter out usages that are at the definition location
|
|
1624
|
+
const nonDefUsages = allUsages.filter(u =>
|
|
1625
|
+
!(u.file === symbol.file && u.line === symbol.startLine)
|
|
1626
|
+
);
|
|
1627
|
+
|
|
1628
|
+
// Total includes all usage types (calls, references, callbacks, re-exports)
|
|
1629
|
+
const totalUsages = nonDefUsages.length;
|
|
1630
|
+
|
|
1631
|
+
if (totalUsages === 0) {
|
|
1632
|
+
results.push({
|
|
1633
|
+
name: symbol.name,
|
|
1634
|
+
type: symbol.type,
|
|
1635
|
+
file: symbol.relativePath,
|
|
1636
|
+
startLine: symbol.startLine,
|
|
1637
|
+
endLine: symbol.endLine,
|
|
1638
|
+
isExported,
|
|
1639
|
+
usageCount: 0
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// Sort by file then line
|
|
1646
|
+
results.sort((a, b) => {
|
|
1647
|
+
if (a.file !== b.file) return a.file.localeCompare(b.file);
|
|
1648
|
+
return a.startLine - b.startLine;
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
return results;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/**
|
|
1655
|
+
* Get dependency graph for a file
|
|
1656
|
+
* @param {string} filePath - Starting file
|
|
1657
|
+
* @param {object} options - { direction: 'imports' | 'importers' | 'both', maxDepth }
|
|
1658
|
+
* @returns {object} - Graph structure with root, nodes, edges
|
|
1659
|
+
*/
|
|
1660
|
+
graph(filePath, options = {}) {
|
|
1661
|
+
const direction = options.direction || 'imports';
|
|
1662
|
+
// Sanitize depth: use default for null/undefined, clamp negative to 0
|
|
1663
|
+
const rawDepth = options.maxDepth ?? 5;
|
|
1664
|
+
const maxDepth = Math.max(0, rawDepth);
|
|
1665
|
+
|
|
1666
|
+
const absPath = path.isAbsolute(filePath)
|
|
1667
|
+
? filePath
|
|
1668
|
+
: path.resolve(this.root, filePath);
|
|
1669
|
+
|
|
1670
|
+
// Try to find file if not exact match
|
|
1671
|
+
let targetPath = absPath;
|
|
1672
|
+
if (!this.files.has(absPath)) {
|
|
1673
|
+
for (const [p, entry] of this.files) {
|
|
1674
|
+
if (entry.relativePath === filePath || p.endsWith(filePath)) {
|
|
1675
|
+
targetPath = p;
|
|
1676
|
+
break;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
if (!this.files.has(targetPath)) {
|
|
1682
|
+
return { root: filePath, nodes: [], edges: [] };
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const visited = new Set();
|
|
1686
|
+
const graph = {
|
|
1687
|
+
root: targetPath,
|
|
1688
|
+
nodes: [],
|
|
1689
|
+
edges: []
|
|
1690
|
+
};
|
|
1691
|
+
|
|
1692
|
+
const traverse = (file, depth) => {
|
|
1693
|
+
if (depth > maxDepth || visited.has(file)) return;
|
|
1694
|
+
visited.add(file);
|
|
1695
|
+
|
|
1696
|
+
const fileEntry = this.files.get(file);
|
|
1697
|
+
const relPath = fileEntry ? fileEntry.relativePath : path.relative(this.root, file);
|
|
1698
|
+
graph.nodes.push({ file, relativePath: relPath, depth });
|
|
1699
|
+
|
|
1700
|
+
let neighbors = [];
|
|
1701
|
+
if (direction === 'imports' || direction === 'both') {
|
|
1702
|
+
const imports = this.importGraph.get(file) || [];
|
|
1703
|
+
neighbors = neighbors.concat(imports);
|
|
1704
|
+
}
|
|
1705
|
+
if (direction === 'importers' || direction === 'both') {
|
|
1706
|
+
const importers = this.exportGraph.get(file) || [];
|
|
1707
|
+
neighbors = neighbors.concat(importers);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
for (const neighbor of neighbors) {
|
|
1711
|
+
graph.edges.push({ from: file, to: neighbor });
|
|
1712
|
+
traverse(neighbor, depth + 1);
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
|
|
1716
|
+
traverse(targetPath, 0);
|
|
1717
|
+
return graph;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Detect patterns that may cause incomplete results
|
|
1722
|
+
* Returns warnings about dynamic code patterns
|
|
1723
|
+
* Cached to avoid rescanning on every query
|
|
1724
|
+
*/
|
|
1725
|
+
detectCompleteness() {
|
|
1726
|
+
// Return cached result if available
|
|
1727
|
+
if (this._completenessCache) {
|
|
1728
|
+
return this._completenessCache;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
const warnings = [];
|
|
1732
|
+
let dynamicImports = 0;
|
|
1733
|
+
let evalUsage = 0;
|
|
1734
|
+
let reflectionUsage = 0;
|
|
1735
|
+
|
|
1736
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
1737
|
+
// Skip node_modules - we don't care about their patterns
|
|
1738
|
+
if (filePath.includes('node_modules')) continue;
|
|
1739
|
+
|
|
1740
|
+
try {
|
|
1741
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1742
|
+
|
|
1743
|
+
// Dynamic imports: import(), require(variable), __import__
|
|
1744
|
+
const dynamicMatches = content.match(/import\s*\([^'"]/g) ||
|
|
1745
|
+
content.match(/require\s*\([^'"]/g) ||
|
|
1746
|
+
content.match(/__import__\s*\(/g);
|
|
1747
|
+
if (dynamicMatches) {
|
|
1748
|
+
dynamicImports += dynamicMatches.length;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// eval, Function constructor, exec (but not exec in comments/strings context)
|
|
1752
|
+
const evalMatches = content.match(/[^a-zA-Z_]eval\s*\(/g) ||
|
|
1753
|
+
content.match(/new\s+Function\s*\(/g);
|
|
1754
|
+
if (evalMatches) {
|
|
1755
|
+
evalUsage += evalMatches.length;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// Reflection: getattr, hasattr, Reflect
|
|
1759
|
+
const reflectMatches = content.match(/\bgetattr\s*\(/g) ||
|
|
1760
|
+
content.match(/\bhasattr\s*\(/g) ||
|
|
1761
|
+
content.match(/\bReflect\./g);
|
|
1762
|
+
if (reflectMatches) {
|
|
1763
|
+
reflectionUsage += reflectMatches.length;
|
|
1764
|
+
}
|
|
1765
|
+
} catch (e) {
|
|
1766
|
+
// Skip unreadable files
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
if (dynamicImports > 0) {
|
|
1771
|
+
warnings.push({
|
|
1772
|
+
type: 'dynamic_imports',
|
|
1773
|
+
count: dynamicImports,
|
|
1774
|
+
message: `${dynamicImports} dynamic import(s) detected - some dependencies may be missed`
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
if (evalUsage > 0) {
|
|
1779
|
+
warnings.push({
|
|
1780
|
+
type: 'eval',
|
|
1781
|
+
count: evalUsage,
|
|
1782
|
+
message: `${evalUsage} eval/exec usage(s) detected - dynamically generated code not analyzed`
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
if (reflectionUsage > 0) {
|
|
1787
|
+
warnings.push({
|
|
1788
|
+
type: 'reflection',
|
|
1789
|
+
count: reflectionUsage,
|
|
1790
|
+
message: `${reflectionUsage} reflection usage(s) detected - dynamic attribute access not tracked`
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
this._completenessCache = {
|
|
1795
|
+
complete: warnings.length === 0,
|
|
1796
|
+
warnings
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
return this._completenessCache;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* Add completeness info to a result
|
|
1804
|
+
*/
|
|
1805
|
+
withCompleteness(result, totalResults, maxResults = 100) {
|
|
1806
|
+
const completeness = {
|
|
1807
|
+
warnings: []
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
if (totalResults > maxResults) {
|
|
1811
|
+
completeness.warnings.push({
|
|
1812
|
+
type: 'truncated',
|
|
1813
|
+
message: `Showing ${maxResults} of ${totalResults} results`
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// Get project-wide completeness
|
|
1818
|
+
const projectCompleteness = this.detectCompleteness();
|
|
1819
|
+
completeness.warnings.push(...projectCompleteness.warnings);
|
|
1820
|
+
|
|
1821
|
+
completeness.complete = completeness.warnings.length === 0;
|
|
1822
|
+
|
|
1823
|
+
return {
|
|
1824
|
+
...result,
|
|
1825
|
+
completeness
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Find related functions - same file, similar names, shared dependencies
|
|
1831
|
+
* This is the "what else should I look at" command
|
|
1832
|
+
*
|
|
1833
|
+
* @param {string} name - Function name
|
|
1834
|
+
* @returns {object} Related functions grouped by relationship type
|
|
1835
|
+
*/
|
|
1836
|
+
related(name) {
|
|
1837
|
+
const definitions = this.symbols.get(name);
|
|
1838
|
+
if (!definitions || definitions.length === 0) {
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
const def = definitions[0];
|
|
1843
|
+
const related = {
|
|
1844
|
+
target: {
|
|
1845
|
+
name: def.name,
|
|
1846
|
+
file: def.relativePath,
|
|
1847
|
+
line: def.startLine,
|
|
1848
|
+
type: def.type
|
|
1849
|
+
},
|
|
1850
|
+
sameFile: [],
|
|
1851
|
+
similarNames: [],
|
|
1852
|
+
sharedCallers: [],
|
|
1853
|
+
sharedCallees: []
|
|
1854
|
+
};
|
|
1855
|
+
|
|
1856
|
+
// 1. Same file functions
|
|
1857
|
+
const fileEntry = this.files.get(def.file);
|
|
1858
|
+
if (fileEntry) {
|
|
1859
|
+
for (const sym of fileEntry.symbols) {
|
|
1860
|
+
if (sym.name !== name && sym.type === 'function') {
|
|
1861
|
+
related.sameFile.push({
|
|
1862
|
+
name: sym.name,
|
|
1863
|
+
line: sym.startLine,
|
|
1864
|
+
params: sym.params
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// 2. Similar names (shared prefix/suffix, camelCase similarity)
|
|
1871
|
+
const nameParts = name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
|
|
1872
|
+
for (const [symName, symbols] of this.symbols) {
|
|
1873
|
+
if (symName === name) continue;
|
|
1874
|
+
const symParts = symName.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
|
|
1875
|
+
|
|
1876
|
+
// Check for shared parts
|
|
1877
|
+
const sharedParts = nameParts.filter(p => symParts.includes(p) && p.length > 2);
|
|
1878
|
+
if (sharedParts.length > 0) {
|
|
1879
|
+
const sym = symbols[0];
|
|
1880
|
+
related.similarNames.push({
|
|
1881
|
+
name: symName,
|
|
1882
|
+
file: sym.relativePath,
|
|
1883
|
+
line: sym.startLine,
|
|
1884
|
+
sharedParts,
|
|
1885
|
+
type: sym.type
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
// Sort by number of shared parts
|
|
1890
|
+
related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
|
|
1891
|
+
related.similarNames = related.similarNames.slice(0, 10);
|
|
1892
|
+
|
|
1893
|
+
// 3. Shared callers - functions called by the same callers
|
|
1894
|
+
const myCallers = new Set(this.findCallers(name).map(c => c.callerName).filter(Boolean));
|
|
1895
|
+
if (myCallers.size > 0) {
|
|
1896
|
+
const callerCounts = new Map();
|
|
1897
|
+
for (const callerName of myCallers) {
|
|
1898
|
+
const callerDef = this.symbols.get(callerName)?.[0];
|
|
1899
|
+
if (callerDef) {
|
|
1900
|
+
const callees = this.findCallees(callerDef);
|
|
1901
|
+
for (const callee of callees) {
|
|
1902
|
+
if (callee.name !== name) {
|
|
1903
|
+
callerCounts.set(callee.name, (callerCounts.get(callee.name) || 0) + 1);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
// Sort by shared caller count
|
|
1909
|
+
const sorted = Array.from(callerCounts.entries())
|
|
1910
|
+
.sort((a, b) => b[1] - a[1])
|
|
1911
|
+
.slice(0, 5);
|
|
1912
|
+
for (const [symName, count] of sorted) {
|
|
1913
|
+
const sym = this.symbols.get(symName)?.[0];
|
|
1914
|
+
if (sym) {
|
|
1915
|
+
related.sharedCallers.push({
|
|
1916
|
+
name: symName,
|
|
1917
|
+
file: sym.relativePath,
|
|
1918
|
+
line: sym.startLine,
|
|
1919
|
+
sharedCallerCount: count
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// 4. Shared callees - functions that call the same things
|
|
1926
|
+
if (def.type === 'function' || def.params !== undefined) {
|
|
1927
|
+
const myCallees = new Set(this.findCallees(def).map(c => c.name));
|
|
1928
|
+
if (myCallees.size > 0) {
|
|
1929
|
+
const calleeCounts = new Map();
|
|
1930
|
+
for (const [symName, symbols] of this.symbols) {
|
|
1931
|
+
if (symName === name) continue;
|
|
1932
|
+
const sym = symbols[0];
|
|
1933
|
+
if (sym.type !== 'function' && sym.params === undefined) continue;
|
|
1934
|
+
|
|
1935
|
+
const theirCallees = this.findCallees(sym);
|
|
1936
|
+
const shared = theirCallees.filter(c => myCallees.has(c.name));
|
|
1937
|
+
if (shared.length > 0) {
|
|
1938
|
+
calleeCounts.set(symName, shared.length);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
// Sort by shared callee count
|
|
1942
|
+
const sorted = Array.from(calleeCounts.entries())
|
|
1943
|
+
.sort((a, b) => b[1] - a[1])
|
|
1944
|
+
.slice(0, 5);
|
|
1945
|
+
for (const [symName, count] of sorted) {
|
|
1946
|
+
const sym = this.symbols.get(symName)?.[0];
|
|
1947
|
+
if (sym) {
|
|
1948
|
+
related.sharedCallees.push({
|
|
1949
|
+
name: symName,
|
|
1950
|
+
file: sym.relativePath,
|
|
1951
|
+
line: sym.startLine,
|
|
1952
|
+
sharedCalleeCount: count
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
return related;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* Trace call flow - show call tree visualization
|
|
1964
|
+
* This is the "what calls what" command
|
|
1965
|
+
*
|
|
1966
|
+
* @param {string} name - Function name to trace from
|
|
1967
|
+
* @param {object} options - { depth, direction }
|
|
1968
|
+
* @returns {object} Call tree structure
|
|
1969
|
+
*/
|
|
1970
|
+
trace(name, options = {}) {
|
|
1971
|
+
// Sanitize depth: use default for null/undefined, clamp negative to 0
|
|
1972
|
+
const rawDepth = options.depth ?? 3;
|
|
1973
|
+
const maxDepth = Math.max(0, rawDepth);
|
|
1974
|
+
const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
|
|
1975
|
+
|
|
1976
|
+
const definitions = this.symbols.get(name);
|
|
1977
|
+
if (!definitions || definitions.length === 0) {
|
|
1978
|
+
return null;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
const def = definitions[0];
|
|
1982
|
+
const visited = new Set();
|
|
1983
|
+
|
|
1984
|
+
const buildTree = (funcName, currentDepth, dir) => {
|
|
1985
|
+
if (currentDepth > maxDepth || visited.has(funcName)) {
|
|
1986
|
+
return null;
|
|
1987
|
+
}
|
|
1988
|
+
visited.add(funcName);
|
|
1989
|
+
|
|
1990
|
+
const funcDefs = this.symbols.get(funcName);
|
|
1991
|
+
if (!funcDefs || funcDefs.length === 0) {
|
|
1992
|
+
return {
|
|
1993
|
+
name: funcName,
|
|
1994
|
+
external: true,
|
|
1995
|
+
children: []
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
const funcDef = funcDefs[0];
|
|
2000
|
+
const node = {
|
|
2001
|
+
name: funcName,
|
|
2002
|
+
file: funcDef.relativePath,
|
|
2003
|
+
line: funcDef.startLine,
|
|
2004
|
+
type: funcDef.type,
|
|
2005
|
+
children: []
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
if (dir === 'down' || dir === 'both') {
|
|
2009
|
+
const callees = this.findCallees(funcDef);
|
|
2010
|
+
for (const callee of callees.slice(0, 10)) { // Limit children
|
|
2011
|
+
const childTree = buildTree(callee.name, currentDepth + 1, 'down');
|
|
2012
|
+
if (childTree) {
|
|
2013
|
+
node.children.push({
|
|
2014
|
+
...childTree,
|
|
2015
|
+
callCount: callee.callCount,
|
|
2016
|
+
weight: callee.weight
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
return node;
|
|
2023
|
+
};
|
|
2024
|
+
|
|
2025
|
+
const tree = buildTree(name, 0, direction);
|
|
2026
|
+
|
|
2027
|
+
// Also get callers if direction is 'up' or 'both'
|
|
2028
|
+
let callers = [];
|
|
2029
|
+
if (direction === 'up' || direction === 'both') {
|
|
2030
|
+
callers = this.findCallers(name).slice(0, 10).map(c => ({
|
|
2031
|
+
name: c.callerName || '(anonymous)',
|
|
2032
|
+
file: c.relativePath,
|
|
2033
|
+
line: c.line,
|
|
2034
|
+
expression: c.content.trim()
|
|
2035
|
+
}));
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
return {
|
|
2039
|
+
root: name,
|
|
2040
|
+
file: def.relativePath,
|
|
2041
|
+
line: def.startLine,
|
|
2042
|
+
direction,
|
|
2043
|
+
maxDepth,
|
|
2044
|
+
tree,
|
|
2045
|
+
callers: direction !== 'down' ? callers : undefined
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Analyze impact of changing a function - what call sites would need updating
|
|
2051
|
+
* This is the "what breaks if I change this" command
|
|
2052
|
+
*
|
|
2053
|
+
* @param {string} name - Function name
|
|
2054
|
+
* @param {object} options - { groupByFile }
|
|
2055
|
+
* @returns {object} Impact analysis
|
|
2056
|
+
*/
|
|
2057
|
+
impact(name, options = {}) {
|
|
2058
|
+
const definitions = this.symbols.get(name);
|
|
2059
|
+
if (!definitions || definitions.length === 0) {
|
|
2060
|
+
return null;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
const def = definitions[0];
|
|
2064
|
+
const usages = this.usages(name, { codeOnly: true });
|
|
2065
|
+
const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
|
|
2066
|
+
|
|
2067
|
+
// Analyze each call site
|
|
2068
|
+
const callSites = calls.map(call => {
|
|
2069
|
+
const analysis = this.analyzeCallSite(call, name);
|
|
2070
|
+
return {
|
|
2071
|
+
file: call.relativePath,
|
|
2072
|
+
line: call.line,
|
|
2073
|
+
expression: call.content.trim(),
|
|
2074
|
+
callerName: this.findEnclosingFunction(call.file, call.line),
|
|
2075
|
+
...analysis
|
|
2076
|
+
};
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
// Group by file if requested
|
|
2080
|
+
const byFile = new Map();
|
|
2081
|
+
for (const site of callSites) {
|
|
2082
|
+
if (!byFile.has(site.file)) {
|
|
2083
|
+
byFile.set(site.file, []);
|
|
2084
|
+
}
|
|
2085
|
+
byFile.get(site.file).push(site);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// Identify patterns
|
|
2089
|
+
const patterns = this.identifyCallPatterns(callSites, name);
|
|
2090
|
+
|
|
2091
|
+
return {
|
|
2092
|
+
function: name,
|
|
2093
|
+
file: def.relativePath,
|
|
2094
|
+
startLine: def.startLine,
|
|
2095
|
+
signature: this.formatSignature(def),
|
|
2096
|
+
params: def.params,
|
|
2097
|
+
paramsStructured: def.paramsStructured,
|
|
2098
|
+
totalCallSites: calls.length,
|
|
2099
|
+
byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
|
|
2100
|
+
file,
|
|
2101
|
+
count: sites.length,
|
|
2102
|
+
sites
|
|
2103
|
+
})),
|
|
2104
|
+
patterns
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* Plan a refactoring operation
|
|
2110
|
+
* @param {string} name - Function name
|
|
2111
|
+
* @param {object} options - { addParam, removeParam, renameTo, defaultValue }
|
|
2112
|
+
* @returns {object} Plan with before/after signatures and affected call sites
|
|
2113
|
+
*/
|
|
2114
|
+
plan(name, options = {}) {
|
|
2115
|
+
const definitions = this.symbols.get(name);
|
|
2116
|
+
if (!definitions || definitions.length === 0) {
|
|
2117
|
+
return { found: false, function: name };
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
const def = definitions[0];
|
|
2121
|
+
const impact = this.impact(name);
|
|
2122
|
+
const currentParams = def.paramsStructured || [];
|
|
2123
|
+
const currentSignature = this.formatSignature(def);
|
|
2124
|
+
|
|
2125
|
+
let newParams = [...currentParams];
|
|
2126
|
+
let newSignature = currentSignature;
|
|
2127
|
+
let operation = null;
|
|
2128
|
+
let changes = [];
|
|
2129
|
+
|
|
2130
|
+
if (options.addParam) {
|
|
2131
|
+
operation = 'add-param';
|
|
2132
|
+
const newParam = {
|
|
2133
|
+
name: options.addParam,
|
|
2134
|
+
...(options.defaultValue && { default: options.defaultValue })
|
|
2135
|
+
};
|
|
2136
|
+
newParams.push(newParam);
|
|
2137
|
+
|
|
2138
|
+
// Generate new signature
|
|
2139
|
+
const paramsList = newParams.map(p => {
|
|
2140
|
+
let str = p.name;
|
|
2141
|
+
if (p.type) str += `: ${p.type}`;
|
|
2142
|
+
if (p.default) str += ` = ${p.default}`;
|
|
2143
|
+
return str;
|
|
2144
|
+
}).join(', ');
|
|
2145
|
+
newSignature = `${name}(${paramsList})`;
|
|
2146
|
+
if (def.returnType) newSignature += `: ${def.returnType}`;
|
|
2147
|
+
|
|
2148
|
+
// Describe changes needed at each call site
|
|
2149
|
+
for (const fileGroup of impact.byFile) {
|
|
2150
|
+
for (const site of fileGroup.sites) {
|
|
2151
|
+
const suggestion = options.defaultValue
|
|
2152
|
+
? `No change needed (has default value)`
|
|
2153
|
+
: `Add argument: ${options.addParam}`;
|
|
2154
|
+
changes.push({
|
|
2155
|
+
file: site.file,
|
|
2156
|
+
line: site.line,
|
|
2157
|
+
expression: site.expression,
|
|
2158
|
+
suggestion,
|
|
2159
|
+
args: site.args
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
if (options.removeParam) {
|
|
2166
|
+
operation = 'remove-param';
|
|
2167
|
+
const paramIndex = currentParams.findIndex(p => p.name === options.removeParam);
|
|
2168
|
+
if (paramIndex === -1) {
|
|
2169
|
+
return {
|
|
2170
|
+
found: true,
|
|
2171
|
+
error: `Parameter "${options.removeParam}" not found in ${name}`,
|
|
2172
|
+
currentParams: currentParams.map(p => p.name)
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
newParams = currentParams.filter(p => p.name !== options.removeParam);
|
|
2177
|
+
|
|
2178
|
+
// Generate new signature
|
|
2179
|
+
const paramsList = newParams.map(p => {
|
|
2180
|
+
let str = p.name;
|
|
2181
|
+
if (p.type) str += `: ${p.type}`;
|
|
2182
|
+
if (p.default) str += ` = ${p.default}`;
|
|
2183
|
+
return str;
|
|
2184
|
+
}).join(', ');
|
|
2185
|
+
newSignature = `${name}(${paramsList})`;
|
|
2186
|
+
if (def.returnType) newSignature += `: ${def.returnType}`;
|
|
2187
|
+
|
|
2188
|
+
// Describe changes at each call site
|
|
2189
|
+
for (const fileGroup of impact.byFile) {
|
|
2190
|
+
for (const site of fileGroup.sites) {
|
|
2191
|
+
if (site.args && site.argCount > paramIndex) {
|
|
2192
|
+
changes.push({
|
|
2193
|
+
file: site.file,
|
|
2194
|
+
line: site.line,
|
|
2195
|
+
expression: site.expression,
|
|
2196
|
+
suggestion: `Remove argument ${paramIndex + 1}: ${site.args[paramIndex] || '?'}`,
|
|
2197
|
+
args: site.args
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
if (options.renameTo) {
|
|
2205
|
+
operation = 'rename';
|
|
2206
|
+
newSignature = currentSignature.replace(name, options.renameTo);
|
|
2207
|
+
|
|
2208
|
+
// All call sites need renaming
|
|
2209
|
+
for (const fileGroup of impact.byFile) {
|
|
2210
|
+
for (const site of fileGroup.sites) {
|
|
2211
|
+
const newExpression = site.expression.replace(
|
|
2212
|
+
new RegExp('\\b' + escapeRegExp(name) + '\\b'),
|
|
2213
|
+
options.renameTo
|
|
2214
|
+
);
|
|
2215
|
+
changes.push({
|
|
2216
|
+
file: site.file,
|
|
2217
|
+
line: site.line,
|
|
2218
|
+
expression: site.expression,
|
|
2219
|
+
suggestion: `Rename to: ${newExpression}`,
|
|
2220
|
+
newExpression
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
return {
|
|
2227
|
+
found: true,
|
|
2228
|
+
function: name,
|
|
2229
|
+
file: def.relativePath,
|
|
2230
|
+
startLine: def.startLine,
|
|
2231
|
+
operation,
|
|
2232
|
+
before: {
|
|
2233
|
+
signature: currentSignature,
|
|
2234
|
+
params: currentParams.map(p => p.name)
|
|
2235
|
+
},
|
|
2236
|
+
after: {
|
|
2237
|
+
signature: newSignature,
|
|
2238
|
+
params: newParams.map(p => p.name)
|
|
2239
|
+
},
|
|
2240
|
+
totalChanges: changes.length,
|
|
2241
|
+
filesAffected: new Set(changes.map(c => c.file)).size,
|
|
2242
|
+
changes
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Parse a stack trace and show code for each frame
|
|
2248
|
+
* @param {string} stackText - Stack trace text
|
|
2249
|
+
* @returns {object} Parsed frames with code context
|
|
2250
|
+
*/
|
|
2251
|
+
parseStackTrace(stackText) {
|
|
2252
|
+
const frames = [];
|
|
2253
|
+
const lines = stackText.split(/\\n|\n/);
|
|
2254
|
+
|
|
2255
|
+
// Stack trace patterns for different languages/runtimes
|
|
2256
|
+
// Order matters - more specific patterns first
|
|
2257
|
+
const patterns = [
|
|
2258
|
+
// JavaScript Node.js: "at functionName (file.js:line:col)" or "at file.js:line:col"
|
|
2259
|
+
{ regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?([^():]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
|
|
2260
|
+
// Deno: "at functionName (file:///path/to/file.ts:line:col)"
|
|
2261
|
+
{ regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?file:\/\/([^:]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
|
|
2262
|
+
// Bun: "at functionName (file.js:line:col)" - similar to Node but may have different formatting
|
|
2263
|
+
{ regex: /^\s+at\s+(.+?)\s+\[as\s+\w+\]\s+\(([^:]+):(\d+):(\d+)\)/, extract: (m) => ({ funcName: m[1], file: m[2], line: parseInt(m[3]), col: parseInt(m[4]) }) },
|
|
2264
|
+
// Browser Chrome/V8: "at functionName (http://... or file:// ...)"
|
|
2265
|
+
{ regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?(?:https?:\/\/[^/]+)?([^():]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
|
|
2266
|
+
// Firefox: "functionName@file:line:col"
|
|
2267
|
+
{ regex: /^(.+)@(.+):(\d+):(\d+)$/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: parseInt(m[4]) }) },
|
|
2268
|
+
// Safari: "functionName@file:line:col" (similar to Firefox)
|
|
2269
|
+
{ regex: /^(.+)@(?:https?:\/\/[^/]+)?([^:]+):(\d+)(?::(\d+))?$/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
|
|
2270
|
+
// Python: "File \"file.py\", line N, in function"
|
|
2271
|
+
{ regex: /File\s+"([^"]+)",\s+line\s+(\d+)(?:,\s+in\s+(.+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), funcName: m[3] || null, col: null }) },
|
|
2272
|
+
// Go: "file.go:line" or "package/file.go:line +0x..."
|
|
2273
|
+
{ regex: /^\s*([^\s:]+\.go):(\d+)(?:\s|$)/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), funcName: null, col: null }) },
|
|
2274
|
+
// Go with function: "package.FunctionName()\n\tfile.go:line"
|
|
2275
|
+
{ regex: /^\s*([^\s(]+)\(\)$/, extract: null }, // Skip function-only lines
|
|
2276
|
+
// Java: "at package.Class.method(File.java:line)"
|
|
2277
|
+
{ regex: /at\s+([^\(]+)\(([^:]+):(\d+)\)/, extract: (m) => ({ funcName: m[1].split('.').pop(), file: m[2], line: parseInt(m[3]), col: null }) },
|
|
2278
|
+
// Rust: "at src/main.rs:line:col" or panic location
|
|
2279
|
+
{ regex: /(?:at\s+)?([^\s:]+\.rs):(\d+)(?::(\d+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), col: m[3] ? parseInt(m[3]) : null, funcName: null }) },
|
|
2280
|
+
// Generic: "file:line" as last resort
|
|
2281
|
+
{ regex: /([^\s:]+\.\w+):(\d+)(?::(\d+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), col: m[3] ? parseInt(m[3]) : null, funcName: null }) }
|
|
2282
|
+
];
|
|
2283
|
+
|
|
2284
|
+
for (const line of lines) {
|
|
2285
|
+
const trimmed = line.trim();
|
|
2286
|
+
if (!trimmed) continue;
|
|
2287
|
+
|
|
2288
|
+
// Try each pattern until one matches
|
|
2289
|
+
for (const pattern of patterns) {
|
|
2290
|
+
const match = pattern.regex.exec(trimmed);
|
|
2291
|
+
if (match && pattern.extract) {
|
|
2292
|
+
const extracted = pattern.extract(match);
|
|
2293
|
+
if (extracted && extracted.file && extracted.line) {
|
|
2294
|
+
frames.push(this.createStackFrame(
|
|
2295
|
+
extracted.file,
|
|
2296
|
+
extracted.line,
|
|
2297
|
+
extracted.funcName,
|
|
2298
|
+
extracted.col,
|
|
2299
|
+
trimmed
|
|
2300
|
+
));
|
|
2301
|
+
break; // Move to next line
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
return {
|
|
2308
|
+
frameCount: frames.length,
|
|
2309
|
+
frames
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Calculate path similarity score between two file paths
|
|
2315
|
+
* Higher score = better match
|
|
2316
|
+
* @param {string} query - The path from stack trace
|
|
2317
|
+
* @param {string} candidate - The candidate file path
|
|
2318
|
+
* @returns {number} Similarity score
|
|
2319
|
+
*/
|
|
2320
|
+
calculatePathSimilarity(query, candidate) {
|
|
2321
|
+
// Normalize paths for comparison
|
|
2322
|
+
const queryParts = query.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
2323
|
+
const candidateParts = candidate.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
2324
|
+
|
|
2325
|
+
let score = 0;
|
|
2326
|
+
|
|
2327
|
+
// Exact match on full path
|
|
2328
|
+
if (candidate.endsWith(query)) {
|
|
2329
|
+
score += 100;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Compare from the end (most important part)
|
|
2333
|
+
let matches = 0;
|
|
2334
|
+
const minLen = Math.min(queryParts.length, candidateParts.length);
|
|
2335
|
+
for (let i = 0; i < minLen; i++) {
|
|
2336
|
+
const queryPart = queryParts[queryParts.length - 1 - i];
|
|
2337
|
+
const candPart = candidateParts[candidateParts.length - 1 - i];
|
|
2338
|
+
if (queryPart === candPart) {
|
|
2339
|
+
matches++;
|
|
2340
|
+
// Earlier parts (closer to filename) score more
|
|
2341
|
+
score += (10 - i) * 5;
|
|
2342
|
+
} else {
|
|
2343
|
+
break; // Stop at first mismatch
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// Bonus for matching most of the query path
|
|
2348
|
+
if (matches === queryParts.length) {
|
|
2349
|
+
score += 50;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Filename match is essential
|
|
2353
|
+
const queryFile = queryParts[queryParts.length - 1];
|
|
2354
|
+
const candFile = candidateParts[candidateParts.length - 1];
|
|
2355
|
+
if (queryFile !== candFile) {
|
|
2356
|
+
score = 0; // No match if filename doesn't match
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
return score;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* Find the best matching file for a stack trace path
|
|
2364
|
+
* @param {string} filePath - Path from stack trace
|
|
2365
|
+
* @param {string|null} funcName - Function name for verification
|
|
2366
|
+
* @param {number} lineNum - Line number for verification
|
|
2367
|
+
* @returns {{path: string, relativePath: string, confidence: number}|null}
|
|
2368
|
+
*/
|
|
2369
|
+
findBestMatchingFile(filePath, funcName, lineNum) {
|
|
2370
|
+
const candidates = [];
|
|
2371
|
+
|
|
2372
|
+
// Collect all potential matches with scores
|
|
2373
|
+
for (const [absPath, fileEntry] of this.files) {
|
|
2374
|
+
const score = this.calculatePathSimilarity(filePath, absPath);
|
|
2375
|
+
const relScore = this.calculatePathSimilarity(filePath, fileEntry.relativePath);
|
|
2376
|
+
const bestScore = Math.max(score, relScore);
|
|
2377
|
+
|
|
2378
|
+
if (bestScore > 0) {
|
|
2379
|
+
candidates.push({
|
|
2380
|
+
absPath,
|
|
2381
|
+
relativePath: fileEntry.relativePath,
|
|
2382
|
+
score: bestScore,
|
|
2383
|
+
fileEntry
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
if (candidates.length === 0) {
|
|
2389
|
+
// Try absolute path
|
|
2390
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.join(this.root, filePath);
|
|
2391
|
+
if (fs.existsSync(absPath)) {
|
|
2392
|
+
return {
|
|
2393
|
+
path: absPath,
|
|
2394
|
+
relativePath: path.relative(this.root, absPath),
|
|
2395
|
+
confidence: 0.5 // Low confidence for unindexed files
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
return null;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// Sort by score descending
|
|
2402
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
2403
|
+
|
|
2404
|
+
// If there's a function name, verify it exists at the line
|
|
2405
|
+
if (funcName && candidates.length > 1) {
|
|
2406
|
+
for (const cand of candidates) {
|
|
2407
|
+
const symbols = this.symbols.get(funcName);
|
|
2408
|
+
if (symbols) {
|
|
2409
|
+
const match = symbols.find(s =>
|
|
2410
|
+
s.file === cand.absPath &&
|
|
2411
|
+
s.startLine <= lineNum && s.endLine >= lineNum
|
|
2412
|
+
);
|
|
2413
|
+
if (match) {
|
|
2414
|
+
// This candidate has the function at the right line - strong match
|
|
2415
|
+
return {
|
|
2416
|
+
path: cand.absPath,
|
|
2417
|
+
relativePath: cand.relativePath,
|
|
2418
|
+
confidence: 1.0,
|
|
2419
|
+
verifiedFunction: true
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// Return best scoring candidate
|
|
2427
|
+
const best = candidates[0];
|
|
2428
|
+
const confidence = candidates.length === 1 ? 0.9 :
|
|
2429
|
+
(best.score > 100 ? 0.8 : 0.6);
|
|
2430
|
+
|
|
2431
|
+
return {
|
|
2432
|
+
path: best.absPath,
|
|
2433
|
+
relativePath: best.relativePath,
|
|
2434
|
+
confidence
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* Create a stack frame with code context
|
|
2440
|
+
*/
|
|
2441
|
+
createStackFrame(filePath, lineNum, funcName, col, rawLine) {
|
|
2442
|
+
const frame = {
|
|
2443
|
+
file: filePath,
|
|
2444
|
+
line: lineNum,
|
|
2445
|
+
function: funcName,
|
|
2446
|
+
column: col,
|
|
2447
|
+
raw: rawLine,
|
|
2448
|
+
found: false,
|
|
2449
|
+
code: null,
|
|
2450
|
+
context: null,
|
|
2451
|
+
confidence: 0
|
|
2452
|
+
};
|
|
2453
|
+
|
|
2454
|
+
// Find the best matching file using improved algorithm
|
|
2455
|
+
const match = this.findBestMatchingFile(filePath, funcName, lineNum);
|
|
2456
|
+
|
|
2457
|
+
if (match) {
|
|
2458
|
+
const resolvedPath = match.path;
|
|
2459
|
+
frame.found = true;
|
|
2460
|
+
frame.resolvedFile = match.relativePath;
|
|
2461
|
+
frame.confidence = match.confidence;
|
|
2462
|
+
if (match.verifiedFunction) {
|
|
2463
|
+
frame.verifiedFunction = true;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
try {
|
|
2467
|
+
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
2468
|
+
const lines = content.split('\n');
|
|
2469
|
+
|
|
2470
|
+
// Get the exact line
|
|
2471
|
+
if (lineNum > 0 && lineNum <= lines.length) {
|
|
2472
|
+
frame.code = lines[lineNum - 1];
|
|
2473
|
+
|
|
2474
|
+
// Get context (2 lines before, 2 after)
|
|
2475
|
+
const contextLines = [];
|
|
2476
|
+
for (let i = Math.max(0, lineNum - 3); i < Math.min(lines.length, lineNum + 2); i++) {
|
|
2477
|
+
contextLines.push({
|
|
2478
|
+
line: i + 1,
|
|
2479
|
+
code: lines[i],
|
|
2480
|
+
isCurrent: i + 1 === lineNum
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
frame.context = contextLines;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// Try to find function info (verify it contains the line)
|
|
2487
|
+
if (funcName) {
|
|
2488
|
+
const symbols = this.symbols.get(funcName);
|
|
2489
|
+
if (symbols) {
|
|
2490
|
+
const funcMatch = symbols.find(s =>
|
|
2491
|
+
s.file === resolvedPath &&
|
|
2492
|
+
s.startLine <= lineNum && s.endLine >= lineNum
|
|
2493
|
+
);
|
|
2494
|
+
if (funcMatch) {
|
|
2495
|
+
frame.functionInfo = {
|
|
2496
|
+
name: funcMatch.name,
|
|
2497
|
+
startLine: funcMatch.startLine,
|
|
2498
|
+
endLine: funcMatch.endLine,
|
|
2499
|
+
params: funcMatch.params
|
|
2500
|
+
};
|
|
2501
|
+
frame.confidence = 1.0; // High confidence when function verified
|
|
2502
|
+
} else {
|
|
2503
|
+
// Function exists but line doesn't match - lower confidence
|
|
2504
|
+
const anyMatch = symbols.find(s => s.file === resolvedPath);
|
|
2505
|
+
if (anyMatch) {
|
|
2506
|
+
frame.functionInfo = {
|
|
2507
|
+
name: anyMatch.name,
|
|
2508
|
+
startLine: anyMatch.startLine,
|
|
2509
|
+
endLine: anyMatch.endLine,
|
|
2510
|
+
params: anyMatch.params,
|
|
2511
|
+
lineMismatch: true
|
|
2512
|
+
};
|
|
2513
|
+
frame.confidence = Math.min(frame.confidence, 0.5);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
} else {
|
|
2518
|
+
// No function name in stack - find enclosing function
|
|
2519
|
+
const enclosing = this.findEnclosingFunction(resolvedPath, lineNum, true);
|
|
2520
|
+
if (enclosing) {
|
|
2521
|
+
frame.functionInfo = {
|
|
2522
|
+
name: enclosing.name,
|
|
2523
|
+
startLine: enclosing.startLine,
|
|
2524
|
+
endLine: enclosing.endLine,
|
|
2525
|
+
params: enclosing.params,
|
|
2526
|
+
inferred: true
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
} catch (e) {
|
|
2531
|
+
frame.error = e.message;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
return frame;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
/**
|
|
2539
|
+
* Verify that all call sites match a function's signature
|
|
2540
|
+
* @param {string} name - Function name
|
|
2541
|
+
* @returns {object} Verification results with mismatches
|
|
2542
|
+
*/
|
|
2543
|
+
verify(name) {
|
|
2544
|
+
const definitions = this.symbols.get(name);
|
|
2545
|
+
if (!definitions || definitions.length === 0) {
|
|
2546
|
+
return { found: false, function: name };
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
const def = definitions[0];
|
|
2550
|
+
const expectedParamCount = def.paramsStructured?.length || 0;
|
|
2551
|
+
const optionalCount = (def.paramsStructured || []).filter(p => p.optional || p.default !== undefined).length;
|
|
2552
|
+
const minArgs = expectedParamCount - optionalCount;
|
|
2553
|
+
const hasRest = (def.paramsStructured || []).some(p => p.rest);
|
|
2554
|
+
|
|
2555
|
+
// Get all call sites
|
|
2556
|
+
const usages = this.usages(name, { codeOnly: true });
|
|
2557
|
+
const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
|
|
2558
|
+
|
|
2559
|
+
const valid = [];
|
|
2560
|
+
const mismatches = [];
|
|
2561
|
+
const uncertain = [];
|
|
2562
|
+
|
|
2563
|
+
for (const call of calls) {
|
|
2564
|
+
const analysis = this.analyzeCallSite(call, name);
|
|
2565
|
+
|
|
2566
|
+
if (analysis.args === null) {
|
|
2567
|
+
// Couldn't parse arguments
|
|
2568
|
+
uncertain.push({
|
|
2569
|
+
file: call.relativePath,
|
|
2570
|
+
line: call.line,
|
|
2571
|
+
expression: call.content.trim(),
|
|
2572
|
+
reason: 'Could not parse call arguments'
|
|
2573
|
+
});
|
|
2574
|
+
continue;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
if (analysis.hasSpread) {
|
|
2578
|
+
// Spread args - can't verify count
|
|
2579
|
+
uncertain.push({
|
|
2580
|
+
file: call.relativePath,
|
|
2581
|
+
line: call.line,
|
|
2582
|
+
expression: call.content.trim(),
|
|
2583
|
+
reason: 'Uses spread operator'
|
|
2584
|
+
});
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
const argCount = analysis.argCount;
|
|
2589
|
+
|
|
2590
|
+
// Check if arg count is valid
|
|
2591
|
+
if (hasRest) {
|
|
2592
|
+
// With rest param, need at least minArgs
|
|
2593
|
+
if (argCount >= minArgs) {
|
|
2594
|
+
valid.push({ file: call.relativePath, line: call.line });
|
|
2595
|
+
} else {
|
|
2596
|
+
mismatches.push({
|
|
2597
|
+
file: call.relativePath,
|
|
2598
|
+
line: call.line,
|
|
2599
|
+
expression: call.content.trim(),
|
|
2600
|
+
expected: `at least ${minArgs} arg(s)`,
|
|
2601
|
+
actual: argCount,
|
|
2602
|
+
args: analysis.args
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
} else {
|
|
2606
|
+
// Without rest, need between minArgs and expectedParamCount
|
|
2607
|
+
if (argCount >= minArgs && argCount <= expectedParamCount) {
|
|
2608
|
+
valid.push({ file: call.relativePath, line: call.line });
|
|
2609
|
+
} else {
|
|
2610
|
+
mismatches.push({
|
|
2611
|
+
file: call.relativePath,
|
|
2612
|
+
line: call.line,
|
|
2613
|
+
expression: call.content.trim(),
|
|
2614
|
+
expected: minArgs === expectedParamCount
|
|
2615
|
+
? `${expectedParamCount} arg(s)`
|
|
2616
|
+
: `${minArgs}-${expectedParamCount} arg(s)`,
|
|
2617
|
+
actual: argCount,
|
|
2618
|
+
args: analysis.args
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
return {
|
|
2625
|
+
found: true,
|
|
2626
|
+
function: name,
|
|
2627
|
+
file: def.relativePath,
|
|
2628
|
+
startLine: def.startLine,
|
|
2629
|
+
signature: this.formatSignature(def),
|
|
2630
|
+
params: def.paramsStructured?.map(p => ({
|
|
2631
|
+
name: p.name,
|
|
2632
|
+
optional: p.optional || p.default !== undefined,
|
|
2633
|
+
hasDefault: p.default !== undefined
|
|
2634
|
+
})) || [],
|
|
2635
|
+
expectedArgs: { min: minArgs, max: hasRest ? '∞' : expectedParamCount },
|
|
2636
|
+
totalCalls: calls.length,
|
|
2637
|
+
valid: valid.length,
|
|
2638
|
+
mismatches: mismatches.length,
|
|
2639
|
+
uncertain: uncertain.length,
|
|
2640
|
+
mismatchDetails: mismatches,
|
|
2641
|
+
uncertainDetails: uncertain
|
|
2642
|
+
};
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
/**
|
|
2646
|
+
* Analyze a call site to understand how it's being called
|
|
2647
|
+
*/
|
|
2648
|
+
analyzeCallSite(call, funcName) {
|
|
2649
|
+
const content = call.content;
|
|
2650
|
+
|
|
2651
|
+
// Extract arguments from the call
|
|
2652
|
+
const callMatch = new RegExp('\\b' + escapeRegExp(funcName) + '\\s*\\(([^)]*)\\)').exec(content);
|
|
2653
|
+
if (!callMatch) {
|
|
2654
|
+
return { args: null, argCount: 0 };
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
const argsStr = callMatch[1].trim();
|
|
2658
|
+
if (!argsStr) {
|
|
2659
|
+
return { args: [], argCount: 0 };
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// Simple arg parsing (doesn't handle nested parens/strings perfectly but good enough)
|
|
2663
|
+
const args = this.parseArguments(argsStr);
|
|
2664
|
+
|
|
2665
|
+
return {
|
|
2666
|
+
args,
|
|
2667
|
+
argCount: args.length,
|
|
2668
|
+
hasSpread: args.some(a => a.startsWith('...')),
|
|
2669
|
+
hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a))
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
/**
|
|
2674
|
+
* Parse function call arguments (simple version)
|
|
2675
|
+
*/
|
|
2676
|
+
parseArguments(argsStr) {
|
|
2677
|
+
const args = [];
|
|
2678
|
+
let current = '';
|
|
2679
|
+
let depth = 0;
|
|
2680
|
+
let inString = false;
|
|
2681
|
+
let stringChar = '';
|
|
2682
|
+
|
|
2683
|
+
for (let i = 0; i < argsStr.length; i++) {
|
|
2684
|
+
const ch = argsStr[i];
|
|
2685
|
+
|
|
2686
|
+
if (inString) {
|
|
2687
|
+
current += ch;
|
|
2688
|
+
if (ch === stringChar && argsStr[i - 1] !== '\\') {
|
|
2689
|
+
inString = false;
|
|
2690
|
+
}
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
2695
|
+
inString = true;
|
|
2696
|
+
stringChar = ch;
|
|
2697
|
+
current += ch;
|
|
2698
|
+
continue;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
if (ch === '(' || ch === '[' || ch === '{') {
|
|
2702
|
+
depth++;
|
|
2703
|
+
current += ch;
|
|
2704
|
+
continue;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
if (ch === ')' || ch === ']' || ch === '}') {
|
|
2708
|
+
depth--;
|
|
2709
|
+
current += ch;
|
|
2710
|
+
continue;
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
if (ch === ',' && depth === 0) {
|
|
2714
|
+
args.push(current.trim());
|
|
2715
|
+
current = '';
|
|
2716
|
+
continue;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
current += ch;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
if (current.trim()) {
|
|
2723
|
+
args.push(current.trim());
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
return args;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* Identify common calling patterns
|
|
2731
|
+
*/
|
|
2732
|
+
identifyCallPatterns(callSites, funcName) {
|
|
2733
|
+
const patterns = {
|
|
2734
|
+
constantArgs: 0, // Call sites with literal/constant arguments
|
|
2735
|
+
variableArgs: 0, // Call sites passing variables
|
|
2736
|
+
chainedCalls: 0, // Calls that are part of method chains
|
|
2737
|
+
awaitedCalls: 0, // Async calls with await
|
|
2738
|
+
spreadCalls: 0 // Calls using spread operator
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2741
|
+
for (const site of callSites) {
|
|
2742
|
+
const expr = site.expression;
|
|
2743
|
+
|
|
2744
|
+
if (site.hasSpread) patterns.spreadCalls++;
|
|
2745
|
+
if (/await\s/.test(expr)) patterns.awaitedCalls++;
|
|
2746
|
+
if (new RegExp('\\.' + escapeRegExp(funcName) + '\\s*\\(').test(expr)) patterns.chainedCalls++;
|
|
2747
|
+
|
|
2748
|
+
if (site.args && site.args.length > 0) {
|
|
2749
|
+
const hasLiteral = site.args.some(a =>
|
|
2750
|
+
/^[\d'"{\[]/.test(a) || a === 'true' || a === 'false' || a === 'null'
|
|
2751
|
+
);
|
|
2752
|
+
if (hasLiteral) patterns.constantArgs++;
|
|
2753
|
+
if (site.hasVariable) patterns.variableArgs++;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
return patterns;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
/**
|
|
2761
|
+
* Get complete information about a symbol - definition, usages, callers, callees, tests, code
|
|
2762
|
+
* This is the "tell me everything" command for AI agents
|
|
2763
|
+
*
|
|
2764
|
+
* @param {string} name - Symbol name
|
|
2765
|
+
* @param {object} options - { maxCallers, maxCallees, withCode, withTypes }
|
|
2766
|
+
* @returns {object} Complete symbol info
|
|
2767
|
+
*/
|
|
2768
|
+
about(name, options = {}) {
|
|
2769
|
+
const maxCallers = options.maxCallers || 5;
|
|
2770
|
+
const maxCallees = options.maxCallees || 5;
|
|
2771
|
+
|
|
2772
|
+
// Find symbol definition(s)
|
|
2773
|
+
const definitions = this.find(name, { exact: true });
|
|
2774
|
+
if (definitions.length === 0) {
|
|
2775
|
+
// Try fuzzy match
|
|
2776
|
+
const fuzzy = this.find(name);
|
|
2777
|
+
if (fuzzy.length === 0) {
|
|
2778
|
+
return null;
|
|
2779
|
+
}
|
|
2780
|
+
// Return suggestion
|
|
2781
|
+
return {
|
|
2782
|
+
found: false,
|
|
2783
|
+
suggestions: fuzzy.slice(0, 5).map(s => ({
|
|
2784
|
+
name: s.name,
|
|
2785
|
+
file: s.relativePath,
|
|
2786
|
+
line: s.startLine,
|
|
2787
|
+
type: s.type,
|
|
2788
|
+
usageCount: s.usageCount
|
|
2789
|
+
}))
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// Use the definition with highest usage count (primary implementation)
|
|
2794
|
+
const primary = definitions[0];
|
|
2795
|
+
const others = definitions.slice(1);
|
|
2796
|
+
|
|
2797
|
+
// Use the actual symbol name (may differ from query if fuzzy matched)
|
|
2798
|
+
const symbolName = primary.name;
|
|
2799
|
+
|
|
2800
|
+
// Get usage counts by type
|
|
2801
|
+
const usages = this.usages(symbolName, { codeOnly: true });
|
|
2802
|
+
const usagesByType = {
|
|
2803
|
+
definitions: usages.filter(u => u.isDefinition).length,
|
|
2804
|
+
calls: usages.filter(u => u.usageType === 'call').length,
|
|
2805
|
+
imports: usages.filter(u => u.usageType === 'import').length,
|
|
2806
|
+
references: usages.filter(u => u.usageType === 'reference').length
|
|
2807
|
+
};
|
|
2808
|
+
|
|
2809
|
+
// Get callers and callees (only for functions)
|
|
2810
|
+
let callers = [];
|
|
2811
|
+
let callees = [];
|
|
2812
|
+
let allCallers = null;
|
|
2813
|
+
let allCallees = null;
|
|
2814
|
+
if (primary.type === 'function' || primary.params !== undefined) {
|
|
2815
|
+
allCallers = this.findCallers(symbolName);
|
|
2816
|
+
callers = allCallers.slice(0, maxCallers).map(c => ({
|
|
2817
|
+
file: c.relativePath,
|
|
2818
|
+
line: c.line,
|
|
2819
|
+
expression: c.content.trim(),
|
|
2820
|
+
callerName: c.callerName
|
|
2821
|
+
}));
|
|
2822
|
+
|
|
2823
|
+
allCallees = this.findCallees(primary);
|
|
2824
|
+
callees = allCallees.slice(0, maxCallees).map(c => ({
|
|
2825
|
+
name: c.name,
|
|
2826
|
+
file: c.relativePath,
|
|
2827
|
+
line: c.startLine,
|
|
2828
|
+
startLine: c.startLine,
|
|
2829
|
+
endLine: c.endLine,
|
|
2830
|
+
weight: c.weight,
|
|
2831
|
+
callCount: c.callCount
|
|
2832
|
+
}));
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// Find tests
|
|
2836
|
+
const tests = this.tests(symbolName);
|
|
2837
|
+
const testSummary = {
|
|
2838
|
+
fileCount: tests.length,
|
|
2839
|
+
totalMatches: tests.reduce((sum, t) => sum + t.matches.length, 0),
|
|
2840
|
+
files: tests.slice(0, 3).map(t => t.file)
|
|
2841
|
+
};
|
|
2842
|
+
|
|
2843
|
+
// Extract code if requested (default: true)
|
|
2844
|
+
let code = null;
|
|
2845
|
+
if (options.withCode !== false) {
|
|
2846
|
+
code = this.extractCode(primary);
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
// Get type definitions if requested
|
|
2850
|
+
let types = [];
|
|
2851
|
+
if (options.withTypes && (primary.params !== undefined || primary.returnType)) {
|
|
2852
|
+
const typeNames = this.extractTypeNames(primary);
|
|
2853
|
+
for (const typeName of typeNames) {
|
|
2854
|
+
const typeSymbols = this.symbols.get(typeName);
|
|
2855
|
+
if (typeSymbols) {
|
|
2856
|
+
for (const sym of typeSymbols) {
|
|
2857
|
+
if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
|
|
2858
|
+
types.push({
|
|
2859
|
+
name: sym.name,
|
|
2860
|
+
type: sym.type,
|
|
2861
|
+
file: sym.relativePath,
|
|
2862
|
+
line: sym.startLine
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
const result = {
|
|
2871
|
+
found: true,
|
|
2872
|
+
symbol: {
|
|
2873
|
+
name: primary.name,
|
|
2874
|
+
type: primary.type,
|
|
2875
|
+
file: primary.relativePath,
|
|
2876
|
+
startLine: primary.startLine,
|
|
2877
|
+
endLine: primary.endLine,
|
|
2878
|
+
params: primary.params,
|
|
2879
|
+
returnType: primary.returnType,
|
|
2880
|
+
modifiers: primary.modifiers,
|
|
2881
|
+
docstring: primary.docstring,
|
|
2882
|
+
signature: this.formatSignature(primary)
|
|
2883
|
+
},
|
|
2884
|
+
usages: usagesByType,
|
|
2885
|
+
totalUsages: usagesByType.calls + usagesByType.imports + usagesByType.references,
|
|
2886
|
+
callers: {
|
|
2887
|
+
total: allCallers?.length ?? 0,
|
|
2888
|
+
top: callers
|
|
2889
|
+
},
|
|
2890
|
+
callees: {
|
|
2891
|
+
total: allCallees?.length ?? 0,
|
|
2892
|
+
top: callees
|
|
2893
|
+
},
|
|
2894
|
+
tests: testSummary,
|
|
2895
|
+
otherDefinitions: others.slice(0, 3).map(d => ({
|
|
2896
|
+
file: d.relativePath,
|
|
2897
|
+
line: d.startLine,
|
|
2898
|
+
usageCount: d.usageCount
|
|
2899
|
+
})),
|
|
2900
|
+
types,
|
|
2901
|
+
code,
|
|
2902
|
+
completeness: this.detectCompleteness()
|
|
2903
|
+
};
|
|
2904
|
+
|
|
2905
|
+
return result;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/**
|
|
2909
|
+
* Search for text across the project
|
|
2910
|
+
* @param {string} term - Search term
|
|
2911
|
+
* @param {object} options - { codeOnly, context }
|
|
2912
|
+
*/
|
|
2913
|
+
search(term, options = {}) {
|
|
2914
|
+
const results = [];
|
|
2915
|
+
// Escape the term to handle special regex characters
|
|
2916
|
+
const regex = new RegExp(escapeRegExp(term), 'gi');
|
|
2917
|
+
|
|
2918
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
2919
|
+
try {
|
|
2920
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
2921
|
+
const lines = content.split('\n');
|
|
2922
|
+
const matches = [];
|
|
2923
|
+
|
|
2924
|
+
// Use AST-based filtering for codeOnly mode when language is supported
|
|
2925
|
+
if (options.codeOnly) {
|
|
2926
|
+
const language = detectLanguage(filePath);
|
|
2927
|
+
if (language) {
|
|
2928
|
+
try {
|
|
2929
|
+
const parser = getParser(language);
|
|
2930
|
+
const { findMatchesWithASTFilter } = require('../languages/utils');
|
|
2931
|
+
const astMatches = findMatchesWithASTFilter(content, term, parser, { codeOnly: true });
|
|
2932
|
+
|
|
2933
|
+
for (const m of astMatches) {
|
|
2934
|
+
const match = {
|
|
2935
|
+
line: m.line,
|
|
2936
|
+
content: m.content
|
|
2937
|
+
};
|
|
2938
|
+
|
|
2939
|
+
// Add context lines if requested
|
|
2940
|
+
if (options.context && options.context > 0) {
|
|
2941
|
+
const idx = m.line - 1;
|
|
2942
|
+
const before = [];
|
|
2943
|
+
const after = [];
|
|
2944
|
+
for (let i = 1; i <= options.context; i++) {
|
|
2945
|
+
if (idx - i >= 0) before.unshift(lines[idx - i]);
|
|
2946
|
+
if (idx + i < lines.length) after.push(lines[idx + i]);
|
|
2947
|
+
}
|
|
2948
|
+
match.before = before;
|
|
2949
|
+
match.after = after;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
matches.push(match);
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
if (matches.length > 0) {
|
|
2956
|
+
results.push({
|
|
2957
|
+
file: fileEntry.relativePath,
|
|
2958
|
+
matches
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
continue; // Skip to next file
|
|
2962
|
+
} catch (e) {
|
|
2963
|
+
// Fall through to regex-based search
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// Fallback to regex-based search (non-codeOnly or unsupported language)
|
|
2969
|
+
lines.forEach((line, idx) => {
|
|
2970
|
+
regex.lastIndex = 0; // Reset regex state
|
|
2971
|
+
if (regex.test(line)) {
|
|
2972
|
+
const lineNum = idx + 1;
|
|
2973
|
+
// Skip if codeOnly and line is comment/string
|
|
2974
|
+
if (options.codeOnly && this.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
const match = {
|
|
2979
|
+
line: idx + 1,
|
|
2980
|
+
content: line
|
|
2981
|
+
};
|
|
2982
|
+
|
|
2983
|
+
// Add context lines if requested
|
|
2984
|
+
if (options.context && options.context > 0) {
|
|
2985
|
+
const before = [];
|
|
2986
|
+
const after = [];
|
|
2987
|
+
for (let i = 1; i <= options.context; i++) {
|
|
2988
|
+
if (idx - i >= 0) before.unshift(lines[idx - i]);
|
|
2989
|
+
if (idx + i < lines.length) after.push(lines[idx + i]);
|
|
2990
|
+
}
|
|
2991
|
+
match.before = before;
|
|
2992
|
+
match.after = after;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
matches.push(match);
|
|
2996
|
+
}
|
|
2997
|
+
});
|
|
2998
|
+
|
|
2999
|
+
if (matches.length > 0) {
|
|
3000
|
+
results.push({
|
|
3001
|
+
file: fileEntry.relativePath,
|
|
3002
|
+
matches
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
} catch (e) {
|
|
3006
|
+
// Skip unreadable files
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
return results;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// ========================================================================
|
|
3014
|
+
// PROJECT INFO
|
|
3015
|
+
// ========================================================================
|
|
3016
|
+
|
|
3017
|
+
/**
|
|
3018
|
+
* Get project statistics
|
|
3019
|
+
*/
|
|
3020
|
+
getStats() {
|
|
3021
|
+
// Count total symbols (not just unique names)
|
|
3022
|
+
let totalSymbols = 0;
|
|
3023
|
+
for (const [name, symbols] of this.symbols) {
|
|
3024
|
+
totalSymbols += symbols.length;
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
const stats = {
|
|
3028
|
+
root: this.root,
|
|
3029
|
+
files: this.files.size,
|
|
3030
|
+
symbols: totalSymbols, // Total symbol count, not unique names
|
|
3031
|
+
buildTime: this.buildTime,
|
|
3032
|
+
byLanguage: {},
|
|
3033
|
+
byType: {}
|
|
3034
|
+
};
|
|
3035
|
+
|
|
3036
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
3037
|
+
const lang = fileEntry.language;
|
|
3038
|
+
if (!stats.byLanguage[lang]) {
|
|
3039
|
+
stats.byLanguage[lang] = { files: 0, lines: 0, symbols: 0 };
|
|
3040
|
+
}
|
|
3041
|
+
stats.byLanguage[lang].files++;
|
|
3042
|
+
stats.byLanguage[lang].lines += fileEntry.lines;
|
|
3043
|
+
stats.byLanguage[lang].symbols += fileEntry.symbols.length;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
for (const [name, symbols] of this.symbols) {
|
|
3047
|
+
for (const sym of symbols) {
|
|
3048
|
+
if (!Object.hasOwn(stats.byType, sym.type)) {
|
|
3049
|
+
stats.byType[sym.type] = 0;
|
|
3050
|
+
}
|
|
3051
|
+
stats.byType[sym.type]++;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
return stats;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
/**
|
|
3059
|
+
* Get TOC for all files
|
|
3060
|
+
*/
|
|
3061
|
+
getToc() {
|
|
3062
|
+
const files = [];
|
|
3063
|
+
let totalFunctions = 0;
|
|
3064
|
+
let totalClasses = 0;
|
|
3065
|
+
let totalState = 0;
|
|
3066
|
+
let totalLines = 0;
|
|
3067
|
+
|
|
3068
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
3069
|
+
const functions = fileEntry.symbols.filter(s => s.type === 'function');
|
|
3070
|
+
const classes = fileEntry.symbols.filter(s =>
|
|
3071
|
+
['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl'].includes(s.type)
|
|
3072
|
+
);
|
|
3073
|
+
const state = fileEntry.symbols.filter(s => s.type === 'state');
|
|
3074
|
+
|
|
3075
|
+
totalFunctions += functions.length;
|
|
3076
|
+
totalClasses += classes.length;
|
|
3077
|
+
totalState += state.length;
|
|
3078
|
+
totalLines += fileEntry.lines;
|
|
3079
|
+
|
|
3080
|
+
files.push({
|
|
3081
|
+
file: fileEntry.relativePath,
|
|
3082
|
+
language: fileEntry.language,
|
|
3083
|
+
lines: fileEntry.lines,
|
|
3084
|
+
functions,
|
|
3085
|
+
classes,
|
|
3086
|
+
state
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
return {
|
|
3091
|
+
totalFiles: files.length,
|
|
3092
|
+
totalLines,
|
|
3093
|
+
totalFunctions,
|
|
3094
|
+
totalClasses,
|
|
3095
|
+
totalState,
|
|
3096
|
+
byFile: files
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// ========================================================================
|
|
3101
|
+
// CACHE METHODS
|
|
3102
|
+
// ========================================================================
|
|
3103
|
+
|
|
3104
|
+
/**
|
|
3105
|
+
* Save index to cache file
|
|
3106
|
+
*
|
|
3107
|
+
* @param {string} [cachePath] - Optional custom cache path
|
|
3108
|
+
* @returns {string} - Path to cache file
|
|
3109
|
+
*/
|
|
3110
|
+
saveCache(cachePath) {
|
|
3111
|
+
const cacheDir = cachePath
|
|
3112
|
+
? path.dirname(cachePath)
|
|
3113
|
+
: path.join(this.root, '.ucn-cache');
|
|
3114
|
+
|
|
3115
|
+
if (!fs.existsSync(cacheDir)) {
|
|
3116
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
const cacheFile = cachePath || path.join(cacheDir, 'index.json');
|
|
3120
|
+
|
|
3121
|
+
// Prepare callsCache for serialization (exclude content to save space)
|
|
3122
|
+
const callsCacheData = [];
|
|
3123
|
+
for (const [filePath, entry] of this.callsCache) {
|
|
3124
|
+
callsCacheData.push([filePath, {
|
|
3125
|
+
mtime: entry.mtime,
|
|
3126
|
+
hash: entry.hash,
|
|
3127
|
+
calls: entry.calls
|
|
3128
|
+
// content is not persisted - will be read on demand
|
|
3129
|
+
}]);
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
const cacheData = {
|
|
3133
|
+
version: 2, // Bump version for new cache format
|
|
3134
|
+
root: this.root,
|
|
3135
|
+
buildTime: this.buildTime,
|
|
3136
|
+
timestamp: Date.now(),
|
|
3137
|
+
files: Array.from(this.files.entries()),
|
|
3138
|
+
symbols: Array.from(this.symbols.entries()),
|
|
3139
|
+
importGraph: Array.from(this.importGraph.entries()),
|
|
3140
|
+
exportGraph: Array.from(this.exportGraph.entries()),
|
|
3141
|
+
extendsGraph: Array.from(this.extendsGraph.entries()),
|
|
3142
|
+
extendedByGraph: Array.from(this.extendedByGraph.entries()),
|
|
3143
|
+
callsCache: callsCacheData
|
|
3144
|
+
};
|
|
3145
|
+
|
|
3146
|
+
fs.writeFileSync(cacheFile, JSON.stringify(cacheData));
|
|
3147
|
+
return cacheFile;
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
/**
|
|
3151
|
+
* Load index from cache file
|
|
3152
|
+
*
|
|
3153
|
+
* @param {string} [cachePath] - Optional custom cache path
|
|
3154
|
+
* @returns {boolean} - True if loaded successfully
|
|
3155
|
+
*/
|
|
3156
|
+
loadCache(cachePath) {
|
|
3157
|
+
const cacheFile = cachePath || path.join(this.root, '.ucn-cache', 'index.json');
|
|
3158
|
+
|
|
3159
|
+
if (!fs.existsSync(cacheFile)) {
|
|
3160
|
+
return false;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
try {
|
|
3164
|
+
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
|
|
3165
|
+
|
|
3166
|
+
// Check version compatibility (support v1 and v2)
|
|
3167
|
+
if (cacheData.version !== 1 && cacheData.version !== 2) {
|
|
3168
|
+
return false;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
// Validate cache structure has required fields
|
|
3172
|
+
if (!Array.isArray(cacheData.files) ||
|
|
3173
|
+
!Array.isArray(cacheData.symbols) ||
|
|
3174
|
+
!Array.isArray(cacheData.importGraph) ||
|
|
3175
|
+
!Array.isArray(cacheData.exportGraph)) {
|
|
3176
|
+
return false;
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
this.files = new Map(cacheData.files);
|
|
3180
|
+
this.symbols = new Map(cacheData.symbols);
|
|
3181
|
+
this.importGraph = new Map(cacheData.importGraph);
|
|
3182
|
+
this.exportGraph = new Map(cacheData.exportGraph);
|
|
3183
|
+
this.buildTime = cacheData.buildTime;
|
|
3184
|
+
|
|
3185
|
+
// Restore optional graphs if present
|
|
3186
|
+
if (Array.isArray(cacheData.extendsGraph)) {
|
|
3187
|
+
this.extendsGraph = new Map(cacheData.extendsGraph);
|
|
3188
|
+
}
|
|
3189
|
+
if (Array.isArray(cacheData.extendedByGraph)) {
|
|
3190
|
+
this.extendedByGraph = new Map(cacheData.extendedByGraph);
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// Restore callsCache if present (v2+)
|
|
3194
|
+
if (Array.isArray(cacheData.callsCache)) {
|
|
3195
|
+
this.callsCache = new Map(cacheData.callsCache);
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
// Rebuild derived graphs to ensure consistency with current config
|
|
3199
|
+
this.buildImportGraph();
|
|
3200
|
+
this.buildInheritanceGraph();
|
|
3201
|
+
|
|
3202
|
+
return true;
|
|
3203
|
+
} catch (e) {
|
|
3204
|
+
return false;
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
/**
|
|
3209
|
+
* Check if cache is stale (any files changed or new files added)
|
|
3210
|
+
*
|
|
3211
|
+
* @returns {boolean} - True if cache needs rebuilding
|
|
3212
|
+
*/
|
|
3213
|
+
isCacheStale() {
|
|
3214
|
+
// Check for new files added to project
|
|
3215
|
+
const pattern = detectProjectPattern(this.root);
|
|
3216
|
+
const currentFiles = expandGlob(pattern, { root: this.root });
|
|
3217
|
+
const cachedPaths = new Set(this.files.keys());
|
|
3218
|
+
|
|
3219
|
+
for (const file of currentFiles) {
|
|
3220
|
+
if (!cachedPaths.has(file)) {
|
|
3221
|
+
return true; // New file found
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
// Check existing cached files for modifications/deletions
|
|
3226
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
3227
|
+
// File deleted
|
|
3228
|
+
if (!fs.existsSync(filePath)) {
|
|
3229
|
+
return true;
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
// File modified - check size first, then mtime, then hash
|
|
3233
|
+
try {
|
|
3234
|
+
const stat = fs.statSync(filePath);
|
|
3235
|
+
|
|
3236
|
+
// If size changed, file changed
|
|
3237
|
+
if (fileEntry.size !== undefined && stat.size !== fileEntry.size) {
|
|
3238
|
+
return true;
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// If mtime matches, file hasn't changed
|
|
3242
|
+
if (fileEntry.mtime && stat.mtimeMs === fileEntry.mtime) {
|
|
3243
|
+
continue;
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
// mtime changed or not stored - verify with hash
|
|
3247
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
3248
|
+
const hash = crypto.createHash('md5').update(content).digest('hex');
|
|
3249
|
+
if (hash !== fileEntry.hash) {
|
|
3250
|
+
return true;
|
|
3251
|
+
}
|
|
3252
|
+
} catch (e) {
|
|
3253
|
+
return true;
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
return false;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
module.exports = { ProjectIndex };
|