ucn 3.8.12 → 3.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +3 -1
- package/.github/workflows/ci.yml +15 -3
- package/.github/workflows/publish.yml +20 -8
- package/README.md +1 -0
- package/cli/index.js +165 -246
- package/core/analysis.js +1400 -0
- package/core/build-worker.js +194 -0
- package/core/cache.js +105 -7
- package/core/callers.js +194 -64
- package/core/deadcode.js +22 -66
- package/core/discovery.js +9 -54
- package/core/execute.js +139 -54
- package/core/graph.js +615 -0
- package/core/output/analysis-ext.js +271 -0
- package/core/output/analysis.js +491 -0
- package/core/output/extraction.js +188 -0
- package/core/output/find.js +355 -0
- package/core/output/graph.js +399 -0
- package/core/output/refactoring.js +293 -0
- package/core/output/reporting.js +331 -0
- package/core/output/search.js +307 -0
- package/core/output/shared.js +271 -0
- package/core/output/tracing.js +416 -0
- package/core/output.js +15 -3293
- package/core/parallel-build.js +165 -0
- package/core/project.js +299 -3633
- package/core/registry.js +59 -0
- package/core/reporting.js +258 -0
- package/core/search.js +890 -0
- package/core/stacktrace.js +1 -1
- package/core/tracing.js +631 -0
- package/core/verify.js +10 -13
- package/eslint.config.js +43 -0
- package/jsconfig.json +10 -0
- package/languages/go.js +21 -2
- package/languages/html.js +8 -0
- package/languages/index.js +102 -40
- package/languages/java.js +13 -0
- package/languages/javascript.js +17 -1
- package/languages/python.js +14 -0
- package/languages/rust.js +13 -0
- package/languages/utils.js +1 -1
- package/mcp/server.js +45 -28
- package/package.json +8 -3
package/core/project.js
CHANGED
|
@@ -8,45 +8,26 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
-
const { execFileSync } = require('child_process');
|
|
12
11
|
const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, DEFAULT_IGNORES } = require('./discovery');
|
|
13
12
|
const { extractImports, extractExports, resolveImport } = require('./imports');
|
|
14
|
-
const { parse,
|
|
15
|
-
const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../languages');
|
|
13
|
+
const { parse, cleanHtmlScriptTags } = require('./parser');
|
|
14
|
+
const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
|
|
16
15
|
const { getTokenTypeAtPosition } = require('../languages/utils');
|
|
17
|
-
const { escapeRegExp, NON_CALLABLE_TYPES
|
|
16
|
+
const { escapeRegExp, NON_CALLABLE_TYPES } = require('./shared');
|
|
18
17
|
const stacktrace = require('./stacktrace');
|
|
19
18
|
const indexCache = require('./cache');
|
|
20
19
|
const deadcodeModule = require('./deadcode');
|
|
21
20
|
const verifyModule = require('./verify');
|
|
22
21
|
const callersModule = require('./callers');
|
|
22
|
+
const tracingModule = require('./tracing');
|
|
23
|
+
const searchModule = require('./search');
|
|
24
|
+
const analysisModule = require('./analysis');
|
|
25
|
+
const graphModule = require('./graph');
|
|
26
|
+
const reportingModule = require('./reporting');
|
|
23
27
|
|
|
24
28
|
// Lazy-initialized per-language keyword sets (populated on first isKeyword call)
|
|
25
29
|
let LANGUAGE_KEYWORDS = null;
|
|
26
30
|
|
|
27
|
-
/**
|
|
28
|
-
* Build a glob-style matcher: * matches any sequence, ? matches one char.
|
|
29
|
-
* Case-insensitive by default. Returns a function (string) => boolean.
|
|
30
|
-
*/
|
|
31
|
-
function buildGlobMatcher(pattern, caseSensitive) {
|
|
32
|
-
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
33
|
-
.replace(/\*/g, '.*')
|
|
34
|
-
.replace(/\?/g, '.');
|
|
35
|
-
const regex = new RegExp('^' + escaped + '$', caseSensitive ? '' : 'i');
|
|
36
|
-
return (name) => regex.test(name);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const STRUCTURAL_TYPES = new Set(['function', 'class', 'call', 'method', 'type']);
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Substring match. Case-insensitive by default.
|
|
43
|
-
*/
|
|
44
|
-
function matchesSubstring(text, pattern, caseSensitive) {
|
|
45
|
-
if (!text) return false;
|
|
46
|
-
if (caseSensitive) return text.includes(pattern);
|
|
47
|
-
return text.toLowerCase().includes(pattern.toLowerCase());
|
|
48
|
-
}
|
|
49
|
-
|
|
50
31
|
/**
|
|
51
32
|
* ProjectIndex - Manages symbol table for a project
|
|
52
33
|
*/
|
|
@@ -94,6 +75,7 @@ class ProjectIndex {
|
|
|
94
75
|
if (!this._opContentCache) {
|
|
95
76
|
this._opContentCache = new Map();
|
|
96
77
|
this._opUsagesCache = new Map();
|
|
78
|
+
this._opCallsCountCache = new Map();
|
|
97
79
|
this._opDepth = 0;
|
|
98
80
|
}
|
|
99
81
|
this._opDepth++;
|
|
@@ -104,6 +86,7 @@ class ProjectIndex {
|
|
|
104
86
|
if (--this._opDepth <= 0) {
|
|
105
87
|
this._opContentCache = null;
|
|
106
88
|
this._opUsagesCache = null;
|
|
89
|
+
this._opCallsCountCache = null;
|
|
107
90
|
this._opDepth = 0;
|
|
108
91
|
}
|
|
109
92
|
}
|
|
@@ -228,15 +211,43 @@ class ProjectIndex {
|
|
|
228
211
|
let indexed = 0;
|
|
229
212
|
let changed = 0;
|
|
230
213
|
if (!this.failedFiles) this.failedFiles = new Set();
|
|
231
|
-
|
|
214
|
+
|
|
215
|
+
// Try parallel build for large projects
|
|
216
|
+
const workersSetting = options.workers;
|
|
217
|
+
const envWorkers = parseInt(process.env.UCN_WORKERS, 10);
|
|
218
|
+
const disableParallel = workersSetting === 0 || envWorkers === 0;
|
|
219
|
+
let usedParallel = false;
|
|
220
|
+
|
|
221
|
+
if (!disableParallel && files.length > 500) {
|
|
232
222
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
223
|
+
const { parallelBuild } = require('./parallel-build');
|
|
224
|
+
const result = parallelBuild(this, files, {
|
|
225
|
+
workerCount: workersSetting > 0 ? workersSetting : (envWorkers > 0 ? envWorkers : undefined),
|
|
226
|
+
quiet,
|
|
227
|
+
});
|
|
228
|
+
if (result !== false) {
|
|
229
|
+
changed = result;
|
|
230
|
+
indexed = files.length;
|
|
231
|
+
usedParallel = true;
|
|
232
|
+
}
|
|
236
233
|
} catch (e) {
|
|
237
|
-
this.failedFiles.add(file); // Track files that fail to index
|
|
238
234
|
if (!quiet) {
|
|
239
|
-
console.error(`
|
|
235
|
+
console.error(`Parallel build failed, falling back to sequential: ${e.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!usedParallel) {
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
try {
|
|
243
|
+
if (this.indexFile(file)) changed++;
|
|
244
|
+
indexed++;
|
|
245
|
+
this.failedFiles.delete(file); // Succeeded now, remove from failed
|
|
246
|
+
} catch (e) {
|
|
247
|
+
this.failedFiles.add(file); // Track files that fail to index
|
|
248
|
+
if (!quiet) {
|
|
249
|
+
console.error(` Warning: Could not index ${file}: ${e.message}`);
|
|
250
|
+
}
|
|
240
251
|
}
|
|
241
252
|
}
|
|
242
253
|
}
|
|
@@ -247,6 +258,13 @@ class ProjectIndex {
|
|
|
247
258
|
this.buildInheritanceGraph();
|
|
248
259
|
}
|
|
249
260
|
|
|
261
|
+
// Build directory→files index for O(1) same-package lookups
|
|
262
|
+
this._buildDirIndex();
|
|
263
|
+
|
|
264
|
+
// Build callee index eagerly: leverages warm parse cache from indexFile() above,
|
|
265
|
+
// avoiding the 2+ minute deferred cost when the first analysis command runs later.
|
|
266
|
+
this.buildCalleeIndex();
|
|
267
|
+
|
|
250
268
|
this.buildTime = Date.now() - startTime;
|
|
251
269
|
|
|
252
270
|
if (!quiet) {
|
|
@@ -308,22 +326,17 @@ class ProjectIndex {
|
|
|
308
326
|
// These are build artifacts, not user-written source code
|
|
309
327
|
// Count lines without splitting: count newlines + 1 (avoids allocating array)
|
|
310
328
|
let lineCount = 1;
|
|
311
|
-
let maxLineLen = 0;
|
|
312
329
|
let longLineCount = 0;
|
|
313
330
|
let lineStart = 0;
|
|
314
331
|
for (let ci = 0; ci < content.length; ci++) {
|
|
315
332
|
if (content.charCodeAt(ci) === 10) { // '\n'
|
|
316
|
-
|
|
317
|
-
if (lineLen > maxLineLen) maxLineLen = lineLen;
|
|
318
|
-
if (lineLen > 1000) longLineCount++;
|
|
333
|
+
if (ci - lineStart > 1000) longLineCount++;
|
|
319
334
|
lineStart = ci + 1;
|
|
320
335
|
lineCount++;
|
|
321
336
|
}
|
|
322
337
|
}
|
|
323
338
|
// Handle last line (no trailing newline)
|
|
324
|
-
|
|
325
|
-
if (lastLineLen > maxLineLen) maxLineLen = lastLineLen;
|
|
326
|
-
if (lastLineLen > 1000) longLineCount++;
|
|
339
|
+
if (content.length - lineStart > 1000) longLineCount++;
|
|
327
340
|
|
|
328
341
|
const isBundled = (() => {
|
|
329
342
|
// Webpack bundles contain __webpack_require__ or __webpack_modules__
|
|
@@ -335,6 +348,13 @@ class ProjectIndex {
|
|
|
335
348
|
return false;
|
|
336
349
|
})();
|
|
337
350
|
|
|
351
|
+
// Detect auto-generated files (e.g., Go client-gen, protobuf, code generators).
|
|
352
|
+
// Check first ~500 chars for common markers. These files are indexed but
|
|
353
|
+
// deprioritized in resolveSymbol() scoring.
|
|
354
|
+
const isGenerated = /^\/\/\s*Code generated\b|^\/\/\s*DO NOT EDIT|^\/\/ @generated|^# Generated by/m.test(
|
|
355
|
+
content.slice(0, 500)
|
|
356
|
+
);
|
|
357
|
+
|
|
338
358
|
const fileEntry = {
|
|
339
359
|
path: filePath,
|
|
340
360
|
relativePath: path.relative(this.root, filePath),
|
|
@@ -350,7 +370,8 @@ class ProjectIndex {
|
|
|
350
370
|
symbols: [],
|
|
351
371
|
bindings: [],
|
|
352
372
|
...(importAliases && { importAliases }),
|
|
353
|
-
...(isBundled && { isBundled: true })
|
|
373
|
+
...(isBundled && { isBundled: true }),
|
|
374
|
+
...(isGenerated && { isGenerated: true })
|
|
354
375
|
};
|
|
355
376
|
fileEntry.dynamicImports = dynamicCount || 0;
|
|
356
377
|
|
|
@@ -455,6 +476,23 @@ class ProjectIndex {
|
|
|
455
476
|
if (this._attrTypeCache) this._attrTypeCache.delete(filePath);
|
|
456
477
|
}
|
|
457
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Build directory→files index for O(1) same-package lookups.
|
|
481
|
+
* Replaces O(N) full-index scans in findCallers and countSymbolUsages.
|
|
482
|
+
*/
|
|
483
|
+
_buildDirIndex() {
|
|
484
|
+
this.dirToFiles = new Map();
|
|
485
|
+
for (const filePath of this.files.keys()) {
|
|
486
|
+
const dir = path.dirname(filePath);
|
|
487
|
+
let list = this.dirToFiles.get(dir);
|
|
488
|
+
if (!list) {
|
|
489
|
+
list = [];
|
|
490
|
+
this.dirToFiles.set(dir, list);
|
|
491
|
+
}
|
|
492
|
+
list.push(filePath);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
458
496
|
/**
|
|
459
497
|
* Build inverted call index: callee name -> Set<filePath>.
|
|
460
498
|
* Built lazily on first findCallers call, from the calls cache.
|
|
@@ -465,7 +503,9 @@ class ProjectIndex {
|
|
|
465
503
|
this.calleeIndex = new Map();
|
|
466
504
|
|
|
467
505
|
for (const [filePath] of this.files) {
|
|
468
|
-
|
|
506
|
+
// Fast path: use pre-populated callsCache (avoids stat per file)
|
|
507
|
+
const cached = this.callsCache.get(filePath);
|
|
508
|
+
const calls = cached ? cached.calls : getCachedCalls(this, filePath);
|
|
469
509
|
if (!calls) continue;
|
|
470
510
|
for (const call of calls) {
|
|
471
511
|
const name = call.name;
|
|
@@ -570,7 +610,7 @@ class ProjectIndex {
|
|
|
570
610
|
// Pre-build filename→files map for Java import resolution (O(1) vs O(n) scan)
|
|
571
611
|
const javaFileIndex = new Map();
|
|
572
612
|
for (const [fp, fe] of this.files) {
|
|
573
|
-
if (fe.language === '
|
|
613
|
+
if (langTraits(fe.language)?.packageScope === 'directory') {
|
|
574
614
|
const dir = path.dirname(fp);
|
|
575
615
|
if (!dirToGoFiles.has(dir)) dirToGoFiles.set(dir, []);
|
|
576
616
|
dirToGoFiles.get(dir).push(fp);
|
|
@@ -610,7 +650,7 @@ class ProjectIndex {
|
|
|
610
650
|
// For Go, a package import means all files in that directory are dependencies
|
|
611
651
|
// (Go packages span multiple files in the same directory)
|
|
612
652
|
const filesToLink = [resolved];
|
|
613
|
-
if (fileEntry.language === '
|
|
653
|
+
if (langTraits(fileEntry.language)?.packageScope === 'directory') {
|
|
614
654
|
const pkgDir = path.dirname(resolved);
|
|
615
655
|
const dirFiles = dirToGoFiles.get(pkgDir) || [];
|
|
616
656
|
const importerIsTest = filePath.endsWith('_test.go');
|
|
@@ -923,6 +963,14 @@ class ProjectIndex {
|
|
|
923
963
|
if (/^(examples?|docs?|vendor|third[_-]?party|benchmarks?|samples?)\//i.test(rp)) {
|
|
924
964
|
score -= 300;
|
|
925
965
|
}
|
|
966
|
+
// Deprioritize auto-generated files (client-gen, protobuf, etc.)
|
|
967
|
+
// Light penalty (-100): generated code checked into the repo is often
|
|
968
|
+
// first-class API surface (Go client-gen, Java GRPC stubs), so prefer
|
|
969
|
+
// hand-written code but don't bury generated definitions.
|
|
970
|
+
const fileEntry = this.files.get(d.file);
|
|
971
|
+
if (fileEntry?.isGenerated) {
|
|
972
|
+
score -= 100;
|
|
973
|
+
}
|
|
926
974
|
// Boost lib/src/core/internal directories (+200)
|
|
927
975
|
if (/^(lib|src|core|internal|pkg|crates)\//i.test(rp)) {
|
|
928
976
|
score += 200;
|
|
@@ -961,7 +1009,7 @@ class ProjectIndex {
|
|
|
961
1009
|
}
|
|
962
1010
|
// For Go, also count importers of sibling files (same package)
|
|
963
1011
|
const candidateEntry = this.files.get(candidate.def.file);
|
|
964
|
-
if (candidateEntry?.language === '
|
|
1012
|
+
if (langTraits(candidateEntry?.language)?.packageScope === 'directory') {
|
|
965
1013
|
const candidateDir = path.dirname(candidate.def.file);
|
|
966
1014
|
for (const [, importedFiles] of this.importGraph) {
|
|
967
1015
|
for (const imp of importedFiles) {
|
|
@@ -1006,104 +1054,9 @@ class ProjectIndex {
|
|
|
1006
1054
|
return { def, definitions, warnings };
|
|
1007
1055
|
}
|
|
1008
1056
|
|
|
1009
|
-
find(name, options
|
|
1010
|
-
this._beginOp();
|
|
1011
|
-
try {
|
|
1012
|
-
// Glob pattern matching (e.g., _update*, handle*Request, get?ata)
|
|
1013
|
-
const isGlob = name.includes('*') || name.includes('?');
|
|
1014
|
-
if (isGlob && !options.exact) {
|
|
1015
|
-
// Bare wildcard: return all symbols
|
|
1016
|
-
const stripped = name.replace(/[*?]/g, '');
|
|
1017
|
-
if (stripped.length === 0) {
|
|
1018
|
-
const all = [];
|
|
1019
|
-
for (const [, symbols] of this.symbols) {
|
|
1020
|
-
for (const sym of symbols) {
|
|
1021
|
-
all.push({ ...sym, _fuzzyScore: 800 });
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
all.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
1025
|
-
return this._applyFindFilters(all, options);
|
|
1026
|
-
}
|
|
1027
|
-
const globRegex = new RegExp('^' + name.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
|
1028
|
-
const matches = [];
|
|
1029
|
-
for (const [symName, symbols] of this.symbols) {
|
|
1030
|
-
if (globRegex.test(symName)) {
|
|
1031
|
-
for (const sym of symbols) {
|
|
1032
|
-
matches.push({ ...sym, _fuzzyScore: 800 });
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
matches.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
1037
|
-
return this._applyFindFilters(matches, options);
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
const matches = this.symbols.get(name) || [];
|
|
1041
|
-
|
|
1042
|
-
if (matches.length === 0 && !options.exact) {
|
|
1043
|
-
// Smart fuzzy search with scoring
|
|
1044
|
-
const candidates = [];
|
|
1045
|
-
for (const [symName, symbols] of this.symbols) {
|
|
1046
|
-
const score = this.fuzzyScore(name, symName);
|
|
1047
|
-
if (score > 0) {
|
|
1048
|
-
for (const sym of symbols) {
|
|
1049
|
-
candidates.push({ ...sym, _fuzzyScore: score });
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
// Sort by fuzzy score descending
|
|
1054
|
-
candidates.sort((a, b) => b._fuzzyScore - a._fuzzyScore);
|
|
1055
|
-
matches.push(...candidates);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
return this._applyFindFilters(matches, options);
|
|
1059
|
-
} finally { this._endOp(); }
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
1063
|
-
* Apply file/exclude/in filters and usage counts to find results
|
|
1064
|
-
*/
|
|
1065
|
-
_applyFindFilters(matches, options) {
|
|
1066
|
-
let filtered = matches;
|
|
1067
|
-
|
|
1068
|
-
// Filter by class name (Class.method syntax)
|
|
1069
|
-
if (options.className) {
|
|
1070
|
-
filtered = filtered.filter(m => m.className === options.className);
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
// Filter by file pattern
|
|
1074
|
-
if (options.file) {
|
|
1075
|
-
filtered = filtered.filter(m =>
|
|
1076
|
-
m.relativePath && m.relativePath.includes(options.file)
|
|
1077
|
-
);
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// Apply semantic filters (--exclude, --in)
|
|
1081
|
-
if (options.exclude || options.in) {
|
|
1082
|
-
filtered = filtered.filter(m =>
|
|
1083
|
-
this.matchesFilters(m.relativePath, { exclude: options.exclude, in: options.in })
|
|
1084
|
-
);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// Skip expensive usage counting when caller doesn't need it
|
|
1088
|
-
if (options.skipCounts) {
|
|
1089
|
-
return filtered;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// Add per-symbol usage counts for disambiguation
|
|
1093
|
-
const withCounts = filtered.map(m => {
|
|
1094
|
-
const counts = this.countSymbolUsages(m);
|
|
1095
|
-
return {
|
|
1096
|
-
...m,
|
|
1097
|
-
usageCount: counts.total,
|
|
1098
|
-
usageCounts: counts // { total, calls, definitions, imports, references }
|
|
1099
|
-
};
|
|
1100
|
-
});
|
|
1057
|
+
find(name, options) { return searchModule.find(this, name, options); }
|
|
1101
1058
|
|
|
1102
|
-
|
|
1103
|
-
withCounts.sort((a, b) => b.usageCount - a.usageCount);
|
|
1104
|
-
|
|
1105
|
-
return withCounts;
|
|
1106
|
-
}
|
|
1059
|
+
_applyFindFilters(matches, options) { return searchModule._applyFindFilters(this, matches, options); }
|
|
1107
1060
|
|
|
1108
1061
|
|
|
1109
1062
|
/**
|
|
@@ -1124,27 +1077,58 @@ class ProjectIndex {
|
|
|
1124
1077
|
if (!this.calleeIndex) this.buildCalleeIndex();
|
|
1125
1078
|
const hasFilters = options.exclude && options.exclude.length > 0;
|
|
1126
1079
|
|
|
1127
|
-
//
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1080
|
+
// Pre-compute which files can reference THIS specific definition
|
|
1081
|
+
const importers = this.exportGraph.get(defFile) || [];
|
|
1082
|
+
const importersSet = new Set(importers);
|
|
1083
|
+
const defEntry = this.files.get(defFile);
|
|
1084
|
+
const isDirectoryScope = langTraits(defEntry?.language)?.packageScope === 'directory';
|
|
1085
|
+
const defDir = isDirectoryScope ? path.dirname(defFile) : null;
|
|
1086
|
+
|
|
1087
|
+
// Count calls from callee index, filtered per-definition.
|
|
1088
|
+
// Use per-operation cache to avoid re-iterating getCachedCalls for the same name
|
|
1089
|
+
// (e.g., `find Run` with 268 definitions sharing the name "Run").
|
|
1090
|
+
let perFileCallCounts;
|
|
1091
|
+
if (this._opCallsCountCache && this._opCallsCountCache.has(name)) {
|
|
1092
|
+
perFileCallCounts = this._opCallsCountCache.get(name);
|
|
1093
|
+
} else {
|
|
1094
|
+
perFileCallCounts = new Map();
|
|
1095
|
+
const calleeFiles = this.calleeIndex.get(name);
|
|
1096
|
+
if (calleeFiles) {
|
|
1097
|
+
const { getCachedCalls } = require('./callers');
|
|
1098
|
+
for (const fp of calleeFiles) {
|
|
1099
|
+
const fileCalls = getCachedCalls(this, fp);
|
|
1100
|
+
if (!fileCalls) continue;
|
|
1101
|
+
let fileCount = 0;
|
|
1102
|
+
for (const c of fileCalls) {
|
|
1103
|
+
if (c.name === name || c.resolvedName === name ||
|
|
1104
|
+
(c.resolvedNames && c.resolvedNames.includes(name))) {
|
|
1105
|
+
fileCount++;
|
|
1106
|
+
}
|
|
1145
1107
|
}
|
|
1108
|
+
if (fileCount > 0) perFileCallCounts.set(fp, fileCount);
|
|
1146
1109
|
}
|
|
1147
1110
|
}
|
|
1111
|
+
if (this._opCallsCountCache) {
|
|
1112
|
+
this._opCallsCountCache.set(name, perFileCallCounts);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Sum calls only from files that can reference THIS definition
|
|
1117
|
+
let calls = 0;
|
|
1118
|
+
for (const [fp, count] of perFileCallCounts) {
|
|
1119
|
+
if (hasFilters) {
|
|
1120
|
+
const fe = this.files.get(fp);
|
|
1121
|
+
if (fe && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
|
|
1122
|
+
}
|
|
1123
|
+
// Per-definition filtering for directory-scoped languages (Go/Java/Rust):
|
|
1124
|
+
// only count calls from files that import from defFile, are in the same
|
|
1125
|
+
// package, or are the definition file itself. For structural type systems
|
|
1126
|
+
// (JS/TS/Python), skip this filter — method calls can come from files
|
|
1127
|
+
// without import relationships (objects passed as parameters, etc.)
|
|
1128
|
+
if (isDirectoryScope && fp !== defFile && !importersSet.has(fp)) {
|
|
1129
|
+
if (path.dirname(fp) !== defDir) continue;
|
|
1130
|
+
}
|
|
1131
|
+
calls += count;
|
|
1148
1132
|
}
|
|
1149
1133
|
|
|
1150
1134
|
// Count definitions from symbol table
|
|
@@ -1158,7 +1142,6 @@ class ProjectIndex {
|
|
|
1158
1142
|
|
|
1159
1143
|
// Count imports from import graph (files that import from defFile and use this name)
|
|
1160
1144
|
let imports = 0;
|
|
1161
|
-
const importers = this.exportGraph.get(defFile) || [];
|
|
1162
1145
|
for (const importer of importers) {
|
|
1163
1146
|
const fe = this.files.get(importer);
|
|
1164
1147
|
if (!fe) continue;
|
|
@@ -1168,6 +1151,25 @@ class ProjectIndex {
|
|
|
1168
1151
|
imports++;
|
|
1169
1152
|
}
|
|
1170
1153
|
}
|
|
1154
|
+
// Same-package: files in same directory don't need imports to reference symbols
|
|
1155
|
+
if (isDirectoryScope) {
|
|
1156
|
+
const pkgDir = defDir;
|
|
1157
|
+
for (const [fp, fe] of this.files) {
|
|
1158
|
+
if (fp === defFile || !fp.endsWith('.go') || path.dirname(fp) !== pkgDir) continue;
|
|
1159
|
+
if (hasFilters && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
|
|
1160
|
+
// Check if already counted as importer
|
|
1161
|
+
if (importersSet.has(fp)) continue;
|
|
1162
|
+
// Check callee index for actual calls from this file
|
|
1163
|
+
if (perFileCallCounts.has(fp)) {
|
|
1164
|
+
// Already counted in calls — don't double-count
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
// Check if this same-package file has text references to the symbol
|
|
1168
|
+
if (fe.importNames && fe.importNames.includes(name)) {
|
|
1169
|
+
imports++;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1171
1173
|
|
|
1172
1174
|
const total = calls + definitions + imports;
|
|
1173
1175
|
return { total, calls, definitions, imports, references: 0 };
|
|
@@ -1185,12 +1187,13 @@ class ProjectIndex {
|
|
|
1185
1187
|
const relevantFiles = new Set([defFile]);
|
|
1186
1188
|
const queue = [defFile];
|
|
1187
1189
|
|
|
1188
|
-
//
|
|
1190
|
+
// Same-package: add all files in the same directory (Go package scope)
|
|
1189
1191
|
const defEntry = this.files.get(defFile);
|
|
1190
|
-
if (defEntry?.language === '
|
|
1192
|
+
if (langTraits(defEntry?.language)?.packageScope === 'directory') {
|
|
1191
1193
|
const pkgDir = path.dirname(defFile);
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
+
const siblings = this.dirToFiles?.get(pkgDir) || [];
|
|
1195
|
+
for (const fp of siblings) {
|
|
1196
|
+
if (fp !== defFile) {
|
|
1194
1197
|
relevantFiles.add(fp);
|
|
1195
1198
|
}
|
|
1196
1199
|
}
|
|
@@ -1287,187 +1290,7 @@ class ProjectIndex {
|
|
|
1287
1290
|
* @param {object} options - { codeOnly, context, exclude, in }
|
|
1288
1291
|
* @returns {Array} Usages grouped as definitions, calls, imports, references
|
|
1289
1292
|
*/
|
|
1290
|
-
usages(name, options
|
|
1291
|
-
this._beginOp();
|
|
1292
|
-
try {
|
|
1293
|
-
const usages = [];
|
|
1294
|
-
|
|
1295
|
-
// Resolve file pattern for --file filter
|
|
1296
|
-
const fileFilter = options.file ? this.resolveFilePathForQuery(options.file) : null;
|
|
1297
|
-
|
|
1298
|
-
// Get definitions (filtered)
|
|
1299
|
-
let allDefinitions = this.symbols.get(name) || [];
|
|
1300
|
-
if (options.className) {
|
|
1301
|
-
allDefinitions = allDefinitions.filter(d => d.className === options.className);
|
|
1302
|
-
}
|
|
1303
|
-
if (fileFilter) {
|
|
1304
|
-
allDefinitions = allDefinitions.filter(d => d.file === fileFilter);
|
|
1305
|
-
}
|
|
1306
|
-
const definitions = options.exclude || options.in
|
|
1307
|
-
? allDefinitions.filter(d => this.matchesFilters(d.relativePath, options))
|
|
1308
|
-
: allDefinitions;
|
|
1309
|
-
|
|
1310
|
-
for (const def of definitions) {
|
|
1311
|
-
usages.push({
|
|
1312
|
-
...def,
|
|
1313
|
-
isDefinition: true,
|
|
1314
|
-
line: def.startLine,
|
|
1315
|
-
content: this.getLineContent(def.file, def.startLine),
|
|
1316
|
-
signature: this.formatSignature(def)
|
|
1317
|
-
});
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
// Scan all files for usages
|
|
1321
|
-
for (const [filePath, fileEntry] of this.files) {
|
|
1322
|
-
// Apply --file filter
|
|
1323
|
-
if (fileFilter && filePath !== fileFilter) {
|
|
1324
|
-
continue;
|
|
1325
|
-
}
|
|
1326
|
-
// Apply filters
|
|
1327
|
-
if (!this.matchesFilters(fileEntry.relativePath, options)) {
|
|
1328
|
-
continue;
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
try {
|
|
1332
|
-
const content = this._readFile(filePath);
|
|
1333
|
-
|
|
1334
|
-
// Fast pre-check: skip if name doesn't appear in file at all
|
|
1335
|
-
if (!content.includes(name)) continue;
|
|
1336
|
-
|
|
1337
|
-
const lines = content.split('\n');
|
|
1338
|
-
|
|
1339
|
-
// Try AST-based detection first (with per-operation cache)
|
|
1340
|
-
const astUsages = this._getCachedUsages(filePath, name);
|
|
1341
|
-
if (astUsages !== null) {
|
|
1342
|
-
// Pre-compute: does any imported project file define this name?
|
|
1343
|
-
// Used to filter namespace member expressions (e.g., DropdownMenuPrimitive.Separator)
|
|
1344
|
-
// while keeping module access patterns (e.g., output.formatExample())
|
|
1345
|
-
let _importedHasDef = null;
|
|
1346
|
-
const importedFileHasDef = () => {
|
|
1347
|
-
if (_importedHasDef !== null) return _importedHasDef;
|
|
1348
|
-
const importedFiles = this.importGraph.get(filePath) || [];
|
|
1349
|
-
_importedHasDef = importedFiles.some(imp => {
|
|
1350
|
-
const impEntry = this.files.get(imp);
|
|
1351
|
-
return impEntry?.symbols?.some(s => s.name === name);
|
|
1352
|
-
});
|
|
1353
|
-
return _importedHasDef;
|
|
1354
|
-
};
|
|
1355
|
-
|
|
1356
|
-
for (const u of astUsages) {
|
|
1357
|
-
// Skip if this is a definition line (already added above)
|
|
1358
|
-
if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
|
|
1359
|
-
continue;
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
// Filter member expressions with unrelated receivers in JS/TS/Python.
|
|
1363
|
-
// Keeps: standalone usages, self/this/cls/super, method calls on known types,
|
|
1364
|
-
// and module access (output.fn()) when the imported file defines the name.
|
|
1365
|
-
// Filters: namespace access to external packages (DropdownMenuPrimitive.Separator).
|
|
1366
|
-
if (u.receiver && !['self', 'this', 'cls', 'super'].includes(u.receiver) &&
|
|
1367
|
-
fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
|
|
1368
|
-
const hasMethodDef = definitions.some(d => d.className);
|
|
1369
|
-
if (!hasMethodDef && !importedFileHasDef()) {
|
|
1370
|
-
continue;
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
const lineContent = lines[u.line - 1] || '';
|
|
1375
|
-
|
|
1376
|
-
const usage = {
|
|
1377
|
-
file: filePath,
|
|
1378
|
-
relativePath: fileEntry.relativePath,
|
|
1379
|
-
line: u.line,
|
|
1380
|
-
content: lineContent,
|
|
1381
|
-
usageType: u.usageType,
|
|
1382
|
-
isDefinition: false,
|
|
1383
|
-
...(u.receiver && { receiver: u.receiver })
|
|
1384
|
-
};
|
|
1385
|
-
|
|
1386
|
-
// Add context lines if requested
|
|
1387
|
-
if (options.context && options.context > 0) {
|
|
1388
|
-
const idx = u.line - 1;
|
|
1389
|
-
const before = [];
|
|
1390
|
-
const after = [];
|
|
1391
|
-
for (let i = 1; i <= options.context; i++) {
|
|
1392
|
-
if (idx - i >= 0) before.unshift(lines[idx - i]);
|
|
1393
|
-
if (idx + i < lines.length) after.push(lines[idx + i]);
|
|
1394
|
-
}
|
|
1395
|
-
usage.before = before;
|
|
1396
|
-
usage.after = after;
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
usages.push(usage);
|
|
1400
|
-
}
|
|
1401
|
-
continue; // Skip to next file
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
// Fallback to regex-based detection
|
|
1405
|
-
const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
|
|
1406
|
-
lines.forEach((line, idx) => {
|
|
1407
|
-
const lineNum = idx + 1;
|
|
1408
|
-
|
|
1409
|
-
// Skip if this is a definition line
|
|
1410
|
-
if (definitions.some(d => d.file === filePath && d.startLine === lineNum)) {
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
if (regex.test(line)) {
|
|
1415
|
-
// Skip if codeOnly and line is comment/string
|
|
1416
|
-
if (options.codeOnly && this.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
|
|
1417
|
-
return;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
// Skip if the match is inside a string literal
|
|
1421
|
-
if (this.isInsideStringAST(content, lineNum, line, name, filePath)) {
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
// Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
|
|
1426
|
-
const usageType = this.classifyUsageAST(content, lineNum, name, filePath) ?? 'reference';
|
|
1427
|
-
|
|
1428
|
-
const usage = {
|
|
1429
|
-
file: filePath,
|
|
1430
|
-
relativePath: fileEntry.relativePath,
|
|
1431
|
-
line: lineNum,
|
|
1432
|
-
content: line,
|
|
1433
|
-
usageType,
|
|
1434
|
-
isDefinition: false
|
|
1435
|
-
};
|
|
1436
|
-
|
|
1437
|
-
// Add context lines if requested
|
|
1438
|
-
if (options.context && options.context > 0) {
|
|
1439
|
-
const before = [];
|
|
1440
|
-
const after = [];
|
|
1441
|
-
for (let i = 1; i <= options.context; i++) {
|
|
1442
|
-
if (idx - i >= 0) before.unshift(lines[idx - i]);
|
|
1443
|
-
if (idx + i < lines.length) after.push(lines[idx + i]);
|
|
1444
|
-
}
|
|
1445
|
-
usage.before = before;
|
|
1446
|
-
usage.after = after;
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
usages.push(usage);
|
|
1450
|
-
}
|
|
1451
|
-
});
|
|
1452
|
-
} catch (e) {
|
|
1453
|
-
// Skip unreadable files
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
// Deduplicate same-file, same-line, same-usageType entries
|
|
1458
|
-
// (e.g., `detectLanguage: parser.detectLanguage` has the name twice on one line)
|
|
1459
|
-
const seen = new Set();
|
|
1460
|
-
const deduped = [];
|
|
1461
|
-
for (const u of usages) {
|
|
1462
|
-
const key = `${u.file}:${u.line}:${u.usageType}:${u.isDefinition}`;
|
|
1463
|
-
if (!seen.has(key)) {
|
|
1464
|
-
seen.add(key);
|
|
1465
|
-
deduped.push(u);
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
return deduped;
|
|
1469
|
-
} finally { this._endOp(); }
|
|
1470
|
-
}
|
|
1293
|
+
usages(name, options) { return searchModule.usages(this, name, options); }
|
|
1471
1294
|
|
|
1472
1295
|
/**
|
|
1473
1296
|
* Find methods that belong to a class/struct/type
|
|
@@ -1522,111 +1345,7 @@ class ProjectIndex {
|
|
|
1522
1345
|
/**
|
|
1523
1346
|
* Get context for a symbol (callers + callees)
|
|
1524
1347
|
*/
|
|
1525
|
-
context(name, options
|
|
1526
|
-
this._beginOp();
|
|
1527
|
-
try {
|
|
1528
|
-
const resolved = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
1529
|
-
let { def, definitions, warnings } = resolved;
|
|
1530
|
-
if (!def) {
|
|
1531
|
-
return null;
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
// Special handling for class/struct/interface types
|
|
1535
|
-
if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
|
|
1536
|
-
const methods = this.findMethodsForType(name);
|
|
1537
|
-
|
|
1538
|
-
let typeCallers = this.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain });
|
|
1539
|
-
// Apply exclude filter
|
|
1540
|
-
if (options.exclude && options.exclude.length > 0) {
|
|
1541
|
-
typeCallers = typeCallers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
const result = {
|
|
1545
|
-
type: def.type,
|
|
1546
|
-
name: name,
|
|
1547
|
-
file: def.relativePath,
|
|
1548
|
-
startLine: def.startLine,
|
|
1549
|
-
endLine: def.endLine,
|
|
1550
|
-
methods: methods.map(m => ({
|
|
1551
|
-
name: m.name,
|
|
1552
|
-
file: m.relativePath,
|
|
1553
|
-
line: m.startLine,
|
|
1554
|
-
params: m.params,
|
|
1555
|
-
returnType: m.returnType,
|
|
1556
|
-
receiver: m.receiver
|
|
1557
|
-
})),
|
|
1558
|
-
// Also include places where the type is used in function parameters/returns
|
|
1559
|
-
callers: typeCallers
|
|
1560
|
-
};
|
|
1561
|
-
|
|
1562
|
-
if (warnings.length > 0) {
|
|
1563
|
-
result.warnings = warnings;
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
return result;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
const stats = { uncertain: 0 };
|
|
1570
|
-
let callers = this.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats, targetDefinitions: [def] });
|
|
1571
|
-
let callees = this.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
|
|
1572
|
-
|
|
1573
|
-
// Apply exclude filter
|
|
1574
|
-
if (options.exclude && options.exclude.length > 0) {
|
|
1575
|
-
callers = callers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
1576
|
-
callees = callees.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// Apply confidence filtering
|
|
1580
|
-
let confidenceFiltered = 0;
|
|
1581
|
-
if (options.minConfidence > 0) {
|
|
1582
|
-
const { filterByConfidence } = require('./confidence');
|
|
1583
|
-
const callerResult = filterByConfidence(callers, options.minConfidence);
|
|
1584
|
-
const calleeResult = filterByConfidence(callees, options.minConfidence);
|
|
1585
|
-
callers = callerResult.kept;
|
|
1586
|
-
callees = calleeResult.kept;
|
|
1587
|
-
confidenceFiltered = callerResult.filtered + calleeResult.filtered;
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
const filesInScope = new Set([def.file]);
|
|
1591
|
-
callers.forEach(c => filesInScope.add(c.file));
|
|
1592
|
-
callees.forEach(c => filesInScope.add(c.file));
|
|
1593
|
-
let dynamicImports = 0;
|
|
1594
|
-
for (const f of filesInScope) {
|
|
1595
|
-
const fe = this.files.get(f);
|
|
1596
|
-
if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
const result = {
|
|
1600
|
-
function: name,
|
|
1601
|
-
file: def.relativePath,
|
|
1602
|
-
startLine: def.startLine,
|
|
1603
|
-
endLine: def.endLine,
|
|
1604
|
-
params: def.params,
|
|
1605
|
-
returnType: def.returnType,
|
|
1606
|
-
callers,
|
|
1607
|
-
callees,
|
|
1608
|
-
meta: {
|
|
1609
|
-
complete: stats.uncertain === 0 && dynamicImports === 0 && confidenceFiltered === 0,
|
|
1610
|
-
skipped: 0,
|
|
1611
|
-
dynamicImports,
|
|
1612
|
-
uncertain: stats.uncertain,
|
|
1613
|
-
confidenceFiltered,
|
|
1614
|
-
includeMethods: !!options.includeMethods,
|
|
1615
|
-
projectLanguage: this._getPredominantLanguage(),
|
|
1616
|
-
// Structural facts for reliability hints
|
|
1617
|
-
...(def.isMethod && { isMethod: true }),
|
|
1618
|
-
...(def.className && { className: def.className }),
|
|
1619
|
-
...(def.receiver && { receiver: def.receiver })
|
|
1620
|
-
}
|
|
1621
|
-
};
|
|
1622
|
-
|
|
1623
|
-
if (warnings.length > 0) {
|
|
1624
|
-
result.warnings = warnings;
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
return result;
|
|
1628
|
-
} finally { this._endOp(); }
|
|
1629
|
-
}
|
|
1348
|
+
context(name, options) { return analysisModule.context(this, name, options); }
|
|
1630
1349
|
|
|
1631
1350
|
/** Get cached call sites for a file, with mtime/hash validation */
|
|
1632
1351
|
getCachedCalls(filePath, options) { return callersModule.getCachedCalls(this, filePath, options); }
|
|
@@ -1692,75 +1411,8 @@ class ProjectIndex {
|
|
|
1692
1411
|
return 'normal';
|
|
1693
1412
|
}
|
|
1694
1413
|
|
|
1695
|
-
/**
|
|
1696
|
-
|
|
1697
|
-
*/
|
|
1698
|
-
smart(name, options = {}) {
|
|
1699
|
-
this._beginOp();
|
|
1700
|
-
try {
|
|
1701
|
-
const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
1702
|
-
if (!def) {
|
|
1703
|
-
return null;
|
|
1704
|
-
}
|
|
1705
|
-
const code = this.extractCode(def);
|
|
1706
|
-
const stats = { uncertain: 0 };
|
|
1707
|
-
const callees = this.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
|
|
1708
|
-
|
|
1709
|
-
const filesInScope = new Set([def.file]);
|
|
1710
|
-
callees.forEach(c => filesInScope.add(c.file));
|
|
1711
|
-
let dynamicImports = 0;
|
|
1712
|
-
for (const f of filesInScope) {
|
|
1713
|
-
const fe = this.files.get(f);
|
|
1714
|
-
if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
// Extract code for each dependency, excluding the exact same function
|
|
1718
|
-
// (but keeping same-name overloads, e.g. Java toJson(Object) vs toJson(Object, Class))
|
|
1719
|
-
const defBindingId = def.bindingId;
|
|
1720
|
-
const dependencies = callees
|
|
1721
|
-
.filter(callee => callee.bindingId !== defBindingId)
|
|
1722
|
-
.map(callee => ({
|
|
1723
|
-
...callee,
|
|
1724
|
-
code: this.extractCode(callee)
|
|
1725
|
-
}));
|
|
1726
|
-
|
|
1727
|
-
// Find type definitions if requested
|
|
1728
|
-
const types = [];
|
|
1729
|
-
if (options.withTypes) {
|
|
1730
|
-
// Look for type annotations in params/return type
|
|
1731
|
-
const typeNames = this.extractTypeNames(def);
|
|
1732
|
-
for (const typeName of typeNames) {
|
|
1733
|
-
const typeSymbols = this.symbols.get(typeName);
|
|
1734
|
-
if (typeSymbols) {
|
|
1735
|
-
for (const sym of typeSymbols) {
|
|
1736
|
-
if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
|
|
1737
|
-
types.push({
|
|
1738
|
-
...sym,
|
|
1739
|
-
code: this.extractCode(sym)
|
|
1740
|
-
});
|
|
1741
|
-
}
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
return {
|
|
1748
|
-
target: {
|
|
1749
|
-
...def,
|
|
1750
|
-
code
|
|
1751
|
-
},
|
|
1752
|
-
dependencies,
|
|
1753
|
-
types,
|
|
1754
|
-
meta: {
|
|
1755
|
-
complete: stats.uncertain === 0 && dynamicImports === 0,
|
|
1756
|
-
skipped: 0,
|
|
1757
|
-
dynamicImports,
|
|
1758
|
-
uncertain: stats.uncertain,
|
|
1759
|
-
projectLanguage: this._getPredominantLanguage()
|
|
1760
|
-
}
|
|
1761
|
-
};
|
|
1762
|
-
} finally { this._endOp(); }
|
|
1763
|
-
}
|
|
1414
|
+
/** Smart extraction: function + dependencies */
|
|
1415
|
+
smart(name, options) { return analysisModule.smart(this, name, options); }
|
|
1764
1416
|
|
|
1765
1417
|
// ========================================================================
|
|
1766
1418
|
// HELPER METHODS
|
|
@@ -2156,349 +1808,50 @@ class ProjectIndex {
|
|
|
2156
1808
|
* @param {string} filePath - File to get imports for
|
|
2157
1809
|
* @returns {Array} Imports with resolved paths
|
|
2158
1810
|
*/
|
|
2159
|
-
imports(filePath) {
|
|
2160
|
-
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2161
|
-
if (typeof resolved !== 'string') return resolved;
|
|
2162
|
-
|
|
2163
|
-
const normalizedPath = resolved;
|
|
2164
|
-
const fileEntry = this.files.get(normalizedPath);
|
|
2165
|
-
if (!fileEntry) {
|
|
2166
|
-
return { error: 'file-not-found', filePath };
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
try {
|
|
2170
|
-
const content = this._readFile(normalizedPath);
|
|
2171
|
-
const { imports: rawImports } = extractImports(content, fileEntry.language);
|
|
2172
|
-
|
|
2173
|
-
const contentLines = content.split('\n');
|
|
2174
|
-
|
|
2175
|
-
return rawImports.map(imp => {
|
|
2176
|
-
// Skip imports with null module (e.g. Rust include! with dynamic path)
|
|
2177
|
-
if (!imp.module) {
|
|
2178
|
-
return {
|
|
2179
|
-
module: null,
|
|
2180
|
-
names: imp.names,
|
|
2181
|
-
type: imp.type,
|
|
2182
|
-
resolved: null,
|
|
2183
|
-
isExternal: false,
|
|
2184
|
-
isDynamic: true,
|
|
2185
|
-
line: null
|
|
2186
|
-
};
|
|
2187
|
-
}
|
|
2188
|
-
|
|
2189
|
-
// Dynamic imports with variable path (e.g. require(varName), import(varExpr)) can't be resolved.
|
|
2190
|
-
// Only JS/TS require()/import() with dynamic=true has unresolvable paths.
|
|
2191
|
-
// Go side-effect/dot imports and Rust glob uses also set dynamic=true but have valid module paths.
|
|
2192
|
-
const isUnresolvableDynamic = imp.dynamic && (imp.type === 'require' || imp.type === 'dynamic');
|
|
2193
|
-
if (isUnresolvableDynamic) {
|
|
2194
|
-
let line = null;
|
|
2195
|
-
for (let i = 0; i < contentLines.length; i++) {
|
|
2196
|
-
if (contentLines[i].includes(imp.module || 'require')) {
|
|
2197
|
-
line = i + 1;
|
|
2198
|
-
break;
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
return {
|
|
2202
|
-
module: imp.module,
|
|
2203
|
-
names: imp.names,
|
|
2204
|
-
type: imp.type,
|
|
2205
|
-
resolved: null,
|
|
2206
|
-
isExternal: false,
|
|
2207
|
-
isDynamic: true,
|
|
2208
|
-
line
|
|
2209
|
-
};
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
let resolved = resolveImport(imp.module, normalizedPath, {
|
|
2213
|
-
aliases: this.config.aliases,
|
|
2214
|
-
language: fileEntry.language,
|
|
2215
|
-
root: this.root
|
|
2216
|
-
});
|
|
2217
|
-
|
|
2218
|
-
// Java package imports: resolve by progressive suffix matching
|
|
2219
|
-
// Handles regular, static (com.pkg.Class.method), and wildcard (com.pkg.Class.*) imports
|
|
2220
|
-
if (!resolved && fileEntry.language === 'java' && !imp.module.startsWith('.')) {
|
|
2221
|
-
resolved = this._resolveJavaPackageImport(imp.module);
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
// Find line number of import
|
|
2225
|
-
let line = null;
|
|
2226
|
-
for (let i = 0; i < contentLines.length; i++) {
|
|
2227
|
-
if (contentLines[i].includes(imp.module)) {
|
|
2228
|
-
line = i + 1;
|
|
2229
|
-
break;
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
|
-
return {
|
|
2234
|
-
module: imp.module,
|
|
2235
|
-
names: imp.names,
|
|
2236
|
-
type: imp.type,
|
|
2237
|
-
resolved: resolved ? path.relative(this.root, resolved) : null,
|
|
2238
|
-
isExternal: !resolved,
|
|
2239
|
-
isDynamic: false,
|
|
2240
|
-
line
|
|
2241
|
-
};
|
|
2242
|
-
});
|
|
2243
|
-
} catch (e) {
|
|
2244
|
-
return [];
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
1811
|
+
imports(filePath) { return graphModule.imports(this, filePath); }
|
|
2247
1812
|
|
|
2248
1813
|
/**
|
|
2249
1814
|
* Get files that import a given file
|
|
2250
1815
|
* @param {string} filePath - File to check
|
|
2251
1816
|
* @returns {Array} Files that import this file
|
|
2252
1817
|
*/
|
|
2253
|
-
exporters(filePath) {
|
|
2254
|
-
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2255
|
-
if (typeof resolved !== 'string') return resolved;
|
|
2256
|
-
|
|
2257
|
-
const targetPath = resolved;
|
|
2258
|
-
|
|
2259
|
-
const importers = this.exportGraph.get(targetPath) || [];
|
|
2260
|
-
|
|
2261
|
-
return importers.map(importerPath => {
|
|
2262
|
-
const fileEntry = this.files.get(importerPath);
|
|
2263
|
-
|
|
2264
|
-
// Find the import line
|
|
2265
|
-
let importLine = null;
|
|
2266
|
-
try {
|
|
2267
|
-
const content = this._readFile(importerPath);
|
|
2268
|
-
const lines = content.split('\n');
|
|
2269
|
-
let targetBasename = path.basename(targetPath, path.extname(targetPath));
|
|
2270
|
-
|
|
2271
|
-
// For __init__.py, search for the package name (parent dir)
|
|
2272
|
-
// e.g., "from tools import X" → search for "tools" not "__init__"
|
|
2273
|
-
if (targetBasename === '__init__') {
|
|
2274
|
-
targetBasename = path.basename(path.dirname(targetPath));
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2278
|
-
if (lines[i].includes(targetBasename) &&
|
|
2279
|
-
(lines[i].includes('import') || lines[i].includes('require') || lines[i].includes('from'))) {
|
|
2280
|
-
importLine = i + 1;
|
|
2281
|
-
break;
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
} catch (e) {
|
|
2285
|
-
// Skip
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
return {
|
|
2289
|
-
file: fileEntry ? fileEntry.relativePath : path.relative(this.root, importerPath),
|
|
2290
|
-
importLine
|
|
2291
|
-
};
|
|
2292
|
-
});
|
|
2293
|
-
}
|
|
1818
|
+
exporters(filePath) { return graphModule.exporters(this, filePath); }
|
|
2294
1819
|
|
|
2295
1820
|
/**
|
|
2296
1821
|
* Find type definitions
|
|
2297
1822
|
* @param {string} name - Type name to find
|
|
2298
1823
|
* @returns {Array} Matching type definitions
|
|
2299
1824
|
*/
|
|
2300
|
-
typedef(name, options
|
|
2301
|
-
const typeKinds = ['type', 'interface', 'enum', 'struct', 'trait', 'class', 'record'];
|
|
2302
|
-
const matches = this.find(name, options);
|
|
2303
|
-
|
|
2304
|
-
return matches.filter(m => typeKinds.includes(m.type)).map(m => ({
|
|
2305
|
-
...m,
|
|
2306
|
-
code: this.extractCode(m)
|
|
2307
|
-
}));
|
|
2308
|
-
}
|
|
1825
|
+
typedef(name, options) { return searchModule.typedef(this, name, options); }
|
|
2309
1826
|
|
|
2310
1827
|
/**
|
|
2311
1828
|
* Find tests for a function or file
|
|
2312
1829
|
* @param {string} nameOrFile - Function name or file path
|
|
2313
1830
|
* @returns {Array} Test files and matches
|
|
2314
1831
|
*/
|
|
2315
|
-
tests(nameOrFile, options
|
|
2316
|
-
this._beginOp();
|
|
2317
|
-
try {
|
|
2318
|
-
const results = [];
|
|
1832
|
+
tests(nameOrFile, options) { return searchModule.tests(this, nameOrFile, options); }
|
|
2319
1833
|
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
// Find all test files
|
|
2327
|
-
const testFiles = [];
|
|
2328
|
-
for (const [filePath, fileEntry] of this.files) {
|
|
2329
|
-
if (isTestFile(fileEntry.relativePath, fileEntry.language)) {
|
|
2330
|
-
testFiles.push({ path: filePath, entry: fileEntry });
|
|
2331
|
-
} else if (fileEntry.language === 'rust') {
|
|
2332
|
-
// Rust idiomatically puts tests in #[cfg(test)] modules inside source files.
|
|
2333
|
-
// Check if file has any symbols with 'test' modifier (#[test] attribute).
|
|
2334
|
-
const hasInlineTests = fileEntry.symbols?.some(s =>
|
|
2335
|
-
s.modifiers?.includes('test')
|
|
2336
|
-
);
|
|
2337
|
-
if (hasInlineTests) {
|
|
2338
|
-
testFiles.push({ path: filePath, entry: fileEntry });
|
|
2339
|
-
}
|
|
2340
|
-
}
|
|
2341
|
-
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Get all exported/public symbols
|
|
1836
|
+
* @param {string} [filePath] - Optional file to limit to
|
|
1837
|
+
* @returns {Array} Exported symbols
|
|
1838
|
+
*/
|
|
1839
|
+
api(filePath, options = {}) { return graphModule.api(this, filePath, options); }
|
|
2342
1840
|
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
1841
|
+
/**
|
|
1842
|
+
* Resolve a file path query to an indexed file (with ambiguity detection)
|
|
1843
|
+
* @param {string} filePath - File path to resolve
|
|
1844
|
+
* @returns {string|{error: string, filePath: string, candidates?: string[]}}
|
|
1845
|
+
*/
|
|
1846
|
+
resolveFilePathForQuery(filePath) {
|
|
1847
|
+
// 1. Exact absolute/relative path match
|
|
1848
|
+
const normalizedPath = path.isAbsolute(filePath)
|
|
1849
|
+
? filePath
|
|
1850
|
+
: path.join(this.root, filePath);
|
|
2346
1851
|
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
// Pre-compile patterns used inside per-line loop
|
|
2351
|
-
const callPattern = new RegExp(escapeRegExp(searchTerm) + '\\s*\\(');
|
|
2352
|
-
const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
|
|
2353
|
-
|
|
2354
|
-
for (const { path: testPath, entry } of testFiles) {
|
|
2355
|
-
try {
|
|
2356
|
-
const content = this._readFile(testPath);
|
|
2357
|
-
const lines = content.split('\n');
|
|
2358
|
-
const matches = [];
|
|
2359
|
-
|
|
2360
|
-
lines.forEach((line, idx) => {
|
|
2361
|
-
if (regex.test(line)) {
|
|
2362
|
-
let matchType = 'reference';
|
|
2363
|
-
if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
|
|
2364
|
-
matchType = 'test-case';
|
|
2365
|
-
} else if (/\b(import|require|from)\b/.test(line)) {
|
|
2366
|
-
matchType = 'import';
|
|
2367
|
-
} else if (callPattern.test(line)) {
|
|
2368
|
-
matchType = 'call';
|
|
2369
|
-
}
|
|
2370
|
-
// Detect if the match is inside a string literal (e.g., 'parseFile' or "parseFile")
|
|
2371
|
-
if (matchType === 'reference' || matchType === 'call') {
|
|
2372
|
-
if (strPattern.test(line)) {
|
|
2373
|
-
matchType = 'string-ref';
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
matches.push({
|
|
2378
|
-
line: idx + 1,
|
|
2379
|
-
content: line.trim(),
|
|
2380
|
-
matchType
|
|
2381
|
-
});
|
|
2382
|
-
}
|
|
2383
|
-
});
|
|
2384
|
-
|
|
2385
|
-
const filtered = options.callsOnly
|
|
2386
|
-
? matches.filter(m => m.matchType === 'call' || m.matchType === 'test-case')
|
|
2387
|
-
: matches;
|
|
2388
|
-
if (filtered.length > 0) {
|
|
2389
|
-
results.push({
|
|
2390
|
-
file: entry.relativePath,
|
|
2391
|
-
matches: filtered
|
|
2392
|
-
});
|
|
2393
|
-
}
|
|
2394
|
-
} catch (e) {
|
|
2395
|
-
// Skip unreadable files
|
|
2396
|
-
}
|
|
2397
|
-
}
|
|
2398
|
-
|
|
2399
|
-
return results;
|
|
2400
|
-
} finally { this._endOp(); }
|
|
2401
|
-
}
|
|
2402
|
-
|
|
2403
|
-
/**
|
|
2404
|
-
* Get all exported/public symbols
|
|
2405
|
-
* @param {string} [filePath] - Optional file to limit to
|
|
2406
|
-
* @returns {Array} Exported symbols
|
|
2407
|
-
*/
|
|
2408
|
-
api(filePath, options = {}) {
|
|
2409
|
-
const results = [];
|
|
2410
|
-
|
|
2411
|
-
let fileIterator;
|
|
2412
|
-
if (filePath) {
|
|
2413
|
-
// Try exact resolution first
|
|
2414
|
-
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2415
|
-
if (typeof resolved === 'string') {
|
|
2416
|
-
const fileEntry = this.files.get(resolved);
|
|
2417
|
-
if (!fileEntry) return { error: 'file-not-found', filePath };
|
|
2418
|
-
fileIterator = [[resolved, fileEntry]];
|
|
2419
|
-
} else {
|
|
2420
|
-
// Fall back to pattern filter (substring match on relative path)
|
|
2421
|
-
const matches = [];
|
|
2422
|
-
for (const [absPath, fe] of this.files) {
|
|
2423
|
-
if (fe.relativePath.includes(filePath)) {
|
|
2424
|
-
matches.push([absPath, fe]);
|
|
2425
|
-
}
|
|
2426
|
-
}
|
|
2427
|
-
if (matches.length === 0) return { error: 'file-not-found', filePath };
|
|
2428
|
-
fileIterator = matches;
|
|
2429
|
-
}
|
|
2430
|
-
} else {
|
|
2431
|
-
fileIterator = this.files.entries();
|
|
2432
|
-
}
|
|
2433
|
-
|
|
2434
|
-
for (const [absPath, fileEntry] of fileIterator) {
|
|
2435
|
-
if (!fileEntry) continue;
|
|
2436
|
-
|
|
2437
|
-
// Skip test files by default (test classes aren't part of public API)
|
|
2438
|
-
if (!options.includeTests && isTestFile(fileEntry.relativePath, fileEntry.language)) {
|
|
2439
|
-
continue;
|
|
2440
|
-
}
|
|
2441
|
-
|
|
2442
|
-
const exportedNames = new Set(fileEntry.exports);
|
|
2443
|
-
|
|
2444
|
-
for (const symbol of fileEntry.symbols) {
|
|
2445
|
-
const isExported = exportedNames.has(symbol.name) ||
|
|
2446
|
-
(symbol.modifiers && symbol.modifiers.includes('export')) ||
|
|
2447
|
-
(symbol.modifiers && symbol.modifiers.includes('public')) ||
|
|
2448
|
-
(fileEntry.language === 'go' && /^[A-Z]/.test(symbol.name));
|
|
2449
|
-
|
|
2450
|
-
if (isExported) {
|
|
2451
|
-
results.push({
|
|
2452
|
-
name: symbol.name,
|
|
2453
|
-
type: symbol.type,
|
|
2454
|
-
file: fileEntry.relativePath,
|
|
2455
|
-
startLine: symbol.startLine,
|
|
2456
|
-
endLine: symbol.endLine,
|
|
2457
|
-
params: symbol.params,
|
|
2458
|
-
returnType: symbol.returnType,
|
|
2459
|
-
signature: this.formatSignature(symbol)
|
|
2460
|
-
});
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
// Add variable exports (export const/let/var) not matched to symbols
|
|
2465
|
-
if (fileEntry.exportDetails) {
|
|
2466
|
-
const matchedNames = new Set(results.filter(r => r.file === fileEntry.relativePath).map(r => r.name));
|
|
2467
|
-
for (const exp of fileEntry.exportDetails) {
|
|
2468
|
-
if (exp.isVariable && !matchedNames.has(exp.name)) {
|
|
2469
|
-
const sig = `${exp.declKind} ${exp.name}${exp.typeAnnotation ? ': ' + exp.typeAnnotation : ''}`;
|
|
2470
|
-
results.push({
|
|
2471
|
-
name: exp.name,
|
|
2472
|
-
type: 'variable',
|
|
2473
|
-
file: fileEntry.relativePath,
|
|
2474
|
-
startLine: exp.line,
|
|
2475
|
-
endLine: exp.line,
|
|
2476
|
-
params: undefined,
|
|
2477
|
-
returnType: exp.typeAnnotation || null,
|
|
2478
|
-
signature: sig
|
|
2479
|
-
});
|
|
2480
|
-
}
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
|
|
2485
|
-
return results;
|
|
2486
|
-
}
|
|
2487
|
-
|
|
2488
|
-
/**
|
|
2489
|
-
* Resolve a file path query to an indexed file (with ambiguity detection)
|
|
2490
|
-
* @param {string} filePath - File path to resolve
|
|
2491
|
-
* @returns {string|{error: string, filePath: string, candidates?: string[]}}
|
|
2492
|
-
*/
|
|
2493
|
-
resolveFilePathForQuery(filePath) {
|
|
2494
|
-
// 1. Exact absolute/relative path match
|
|
2495
|
-
const normalizedPath = path.isAbsolute(filePath)
|
|
2496
|
-
? filePath
|
|
2497
|
-
: path.join(this.root, filePath);
|
|
2498
|
-
|
|
2499
|
-
if (this.files.has(normalizedPath)) {
|
|
2500
|
-
return normalizedPath;
|
|
2501
|
-
}
|
|
1852
|
+
if (this.files.has(normalizedPath)) {
|
|
1853
|
+
return normalizedPath;
|
|
1854
|
+
}
|
|
2502
1855
|
|
|
2503
1856
|
// 2. Collect ALL suffix/partial candidates
|
|
2504
1857
|
const candidates = [];
|
|
@@ -2536,193 +1889,7 @@ class ProjectIndex {
|
|
|
2536
1889
|
* @param {string} filePath - File path
|
|
2537
1890
|
* @returns {Array} Exported symbols from that file
|
|
2538
1891
|
*/
|
|
2539
|
-
fileExports(filePath, _visited) {
|
|
2540
|
-
const resolved = this.resolveFilePathForQuery(filePath);
|
|
2541
|
-
if (typeof resolved !== 'string') return resolved;
|
|
2542
|
-
|
|
2543
|
-
const absPath = resolved;
|
|
2544
|
-
const visited = _visited || new Set();
|
|
2545
|
-
if (visited.has(absPath)) return [];
|
|
2546
|
-
visited.add(absPath);
|
|
2547
|
-
|
|
2548
|
-
const fileEntry = this.files.get(absPath);
|
|
2549
|
-
if (!fileEntry) {
|
|
2550
|
-
return [];
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
const results = [];
|
|
2554
|
-
const exportedNames = new Set(fileEntry.exports);
|
|
2555
|
-
|
|
2556
|
-
for (const symbol of fileEntry.symbols) {
|
|
2557
|
-
const isExported = exportedNames.has(symbol.name) ||
|
|
2558
|
-
(symbol.modifiers && symbol.modifiers.includes('export')) ||
|
|
2559
|
-
(symbol.modifiers && symbol.modifiers.includes('public')) ||
|
|
2560
|
-
(fileEntry.language === 'go' && /^[A-Z]/.test(symbol.name));
|
|
2561
|
-
|
|
2562
|
-
if (isExported) {
|
|
2563
|
-
results.push({
|
|
2564
|
-
name: symbol.name,
|
|
2565
|
-
type: symbol.type,
|
|
2566
|
-
file: fileEntry.relativePath,
|
|
2567
|
-
startLine: symbol.startLine,
|
|
2568
|
-
endLine: symbol.endLine,
|
|
2569
|
-
params: symbol.params,
|
|
2570
|
-
returnType: symbol.returnType,
|
|
2571
|
-
signature: this.formatSignature(symbol)
|
|
2572
|
-
});
|
|
2573
|
-
}
|
|
2574
|
-
}
|
|
2575
|
-
|
|
2576
|
-
// Add variable exports (export const/let/var) not matched to symbols
|
|
2577
|
-
if (fileEntry.exportDetails) {
|
|
2578
|
-
const matchedNames = new Set(results.map(r => r.name));
|
|
2579
|
-
for (const exp of fileEntry.exportDetails) {
|
|
2580
|
-
if (exp.isVariable && !matchedNames.has(exp.name)) {
|
|
2581
|
-
const sig = `${exp.declKind} ${exp.name}${exp.typeAnnotation ? ': ' + exp.typeAnnotation : ''}`;
|
|
2582
|
-
results.push({
|
|
2583
|
-
name: exp.name,
|
|
2584
|
-
type: 'variable',
|
|
2585
|
-
file: fileEntry.relativePath,
|
|
2586
|
-
startLine: exp.line,
|
|
2587
|
-
endLine: exp.line,
|
|
2588
|
-
params: undefined,
|
|
2589
|
-
returnType: exp.typeAnnotation || null,
|
|
2590
|
-
signature: sig
|
|
2591
|
-
});
|
|
2592
|
-
}
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
|
-
// Add re-exports: export { X } from './module'
|
|
2596
|
-
// Resolve to the source file and look up the symbol there
|
|
2597
|
-
for (const exp of fileEntry.exportDetails) {
|
|
2598
|
-
if ((exp.type === 're-export' || exp.type === 're-export-all') && exp.source && !matchedNames.has(exp.name)) {
|
|
2599
|
-
const { resolveImport } = require('./imports');
|
|
2600
|
-
const resolved = resolveImport(exp.source, absPath, {
|
|
2601
|
-
language: fileEntry.language,
|
|
2602
|
-
root: this.root,
|
|
2603
|
-
extensions: this.extensions
|
|
2604
|
-
});
|
|
2605
|
-
if (resolved) {
|
|
2606
|
-
const sourceEntry = this.files.get(resolved);
|
|
2607
|
-
if (sourceEntry) {
|
|
2608
|
-
// For star re-exports, include all exported symbols from source
|
|
2609
|
-
if (exp.type === 're-export-all') {
|
|
2610
|
-
const sourceExports = this.fileExports(resolved, visited);
|
|
2611
|
-
for (const srcExp of sourceExports) {
|
|
2612
|
-
if (!matchedNames.has(srcExp.name)) {
|
|
2613
|
-
matchedNames.add(srcExp.name);
|
|
2614
|
-
results.push({ ...srcExp, file: fileEntry.relativePath, reExportedFrom: srcExp.file });
|
|
2615
|
-
}
|
|
2616
|
-
}
|
|
2617
|
-
} else {
|
|
2618
|
-
// Named re-export: find the specific symbol
|
|
2619
|
-
const srcSymbol = sourceEntry.symbols.find(s => s.name === exp.name);
|
|
2620
|
-
if (srcSymbol) {
|
|
2621
|
-
matchedNames.add(exp.name);
|
|
2622
|
-
results.push({
|
|
2623
|
-
name: exp.name,
|
|
2624
|
-
type: srcSymbol.type,
|
|
2625
|
-
file: fileEntry.relativePath,
|
|
2626
|
-
startLine: exp.line,
|
|
2627
|
-
endLine: exp.line,
|
|
2628
|
-
params: srcSymbol.params,
|
|
2629
|
-
returnType: srcSymbol.returnType,
|
|
2630
|
-
signature: this.formatSignature(srcSymbol),
|
|
2631
|
-
reExportedFrom: sourceEntry.relativePath
|
|
2632
|
-
});
|
|
2633
|
-
} else {
|
|
2634
|
-
// Symbol not found in source — still list it as a re-export
|
|
2635
|
-
matchedNames.add(exp.name);
|
|
2636
|
-
results.push({
|
|
2637
|
-
name: exp.name,
|
|
2638
|
-
type: 're-export',
|
|
2639
|
-
file: fileEntry.relativePath,
|
|
2640
|
-
startLine: exp.line,
|
|
2641
|
-
endLine: exp.line,
|
|
2642
|
-
params: undefined,
|
|
2643
|
-
returnType: null,
|
|
2644
|
-
signature: `re-export ${exp.name} from '${exp.source}'`,
|
|
2645
|
-
reExportedFrom: sourceEntry.relativePath
|
|
2646
|
-
});
|
|
2647
|
-
}
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
}
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
// Python __all__ re-exports: names listed in __all__ that come from imports
|
|
2656
|
-
// e.g. __init__.py: `from .utils import helper` + `__all__ = ["helper"]`
|
|
2657
|
-
// `helper` is in fileEntry.exports but not in fileEntry.symbols
|
|
2658
|
-
if (fileEntry.language === 'python' && fileEntry.exports.length > 0) {
|
|
2659
|
-
const matchedNames = new Set(results.map(r => r.name));
|
|
2660
|
-
const unmatched = fileEntry.exports.filter(name => !matchedNames.has(name));
|
|
2661
|
-
if (unmatched.length > 0) {
|
|
2662
|
-
// Re-extract raw imports to get name→module mapping (not stored in fileEntry)
|
|
2663
|
-
const { extractImports, resolveImport } = require('./imports');
|
|
2664
|
-
try {
|
|
2665
|
-
const content = this._readFile(absPath);
|
|
2666
|
-
const { imports: rawImports } = extractImports(content, 'python');
|
|
2667
|
-
// Build name→module map from raw imports
|
|
2668
|
-
const nameToModule = new Map();
|
|
2669
|
-
for (const imp of rawImports) {
|
|
2670
|
-
if (imp.names) {
|
|
2671
|
-
for (const name of imp.names) {
|
|
2672
|
-
if (name !== '*') nameToModule.set(name, imp.module);
|
|
2673
|
-
}
|
|
2674
|
-
}
|
|
2675
|
-
}
|
|
2676
|
-
for (const name of unmatched) {
|
|
2677
|
-
const sourceModule = nameToModule.get(name);
|
|
2678
|
-
if (!sourceModule) continue;
|
|
2679
|
-
const resolvedSrc = resolveImport(sourceModule, absPath, {
|
|
2680
|
-
language: 'python',
|
|
2681
|
-
root: this.root,
|
|
2682
|
-
extensions: this.extensions
|
|
2683
|
-
});
|
|
2684
|
-
if (!resolvedSrc) continue;
|
|
2685
|
-
const sourceEntry = this.files.get(resolvedSrc);
|
|
2686
|
-
const srcSymbol = sourceEntry && sourceEntry.symbols.find(s => s.name === name);
|
|
2687
|
-
if (srcSymbol) {
|
|
2688
|
-
matchedNames.add(name);
|
|
2689
|
-
results.push({
|
|
2690
|
-
name,
|
|
2691
|
-
type: srcSymbol.type,
|
|
2692
|
-
file: fileEntry.relativePath,
|
|
2693
|
-
startLine: srcSymbol.startLine,
|
|
2694
|
-
endLine: srcSymbol.endLine,
|
|
2695
|
-
params: srcSymbol.params,
|
|
2696
|
-
returnType: srcSymbol.returnType,
|
|
2697
|
-
signature: this.formatSignature(srcSymbol),
|
|
2698
|
-
reExportedFrom: sourceEntry.relativePath
|
|
2699
|
-
});
|
|
2700
|
-
} else {
|
|
2701
|
-
// Source not indexed or symbol not found — still list it
|
|
2702
|
-
matchedNames.add(name);
|
|
2703
|
-
results.push({
|
|
2704
|
-
name,
|
|
2705
|
-
type: 're-export',
|
|
2706
|
-
file: fileEntry.relativePath,
|
|
2707
|
-
startLine: undefined,
|
|
2708
|
-
endLine: undefined,
|
|
2709
|
-
params: undefined,
|
|
2710
|
-
returnType: null,
|
|
2711
|
-
signature: `re-export ${name} from '${sourceModule}'`,
|
|
2712
|
-
reExportedFrom: resolvedSrc
|
|
2713
|
-
? (sourceEntry ? sourceEntry.relativePath : resolvedSrc)
|
|
2714
|
-
: sourceModule
|
|
2715
|
-
});
|
|
2716
|
-
}
|
|
2717
|
-
}
|
|
2718
|
-
} catch (_) {
|
|
2719
|
-
// File read failure — skip Python re-export resolution
|
|
2720
|
-
}
|
|
2721
|
-
}
|
|
2722
|
-
}
|
|
2723
|
-
|
|
2724
|
-
return results;
|
|
2725
|
-
}
|
|
1892
|
+
fileExports(filePath, _visited) { return graphModule.fileExports(this, filePath, _visited); }
|
|
2726
1893
|
|
|
2727
1894
|
/** Check if a function is used as a callback anywhere in the codebase */
|
|
2728
1895
|
findCallbackUsages(name) { return callersModule.findCallbackUsages(this, name); }
|
|
@@ -2740,2651 +1907,150 @@ class ProjectIndex {
|
|
|
2740
1907
|
* @param {object} options - { direction: 'imports' | 'importers' | 'both', maxDepth }
|
|
2741
1908
|
* @returns {object} - Graph structure with root, nodes, edges
|
|
2742
1909
|
*/
|
|
2743
|
-
graph(filePath, options = {}) {
|
|
2744
|
-
const direction = options.direction || 'both';
|
|
2745
|
-
// Sanitize depth: use default for null/undefined, clamp negative to 0
|
|
2746
|
-
const rawDepth = options.maxDepth ?? 5;
|
|
2747
|
-
const maxDepth = Math.max(0, rawDepth);
|
|
1910
|
+
graph(filePath, options = {}) { return graphModule.graph(this, filePath, options); }
|
|
2748
1911
|
|
|
2749
|
-
|
|
2750
|
-
|
|
1912
|
+
/**
|
|
1913
|
+
* Detect circular dependencies in the import graph.
|
|
1914
|
+
* Uses DFS with 3-color marking to find all cycles.
|
|
1915
|
+
* @param {object} options - { file, exclude }
|
|
1916
|
+
* @returns {object} - { cycles, totalFiles, summary }
|
|
1917
|
+
*/
|
|
1918
|
+
circularDeps(options = {}) { return graphModule.circularDeps(this, options); }
|
|
2751
1919
|
|
|
2752
|
-
|
|
1920
|
+
/**
|
|
1921
|
+
* Detect patterns that may cause incomplete results
|
|
1922
|
+
* Returns warnings about dynamic code patterns
|
|
1923
|
+
* Cached to avoid rescanning on every query
|
|
1924
|
+
*/
|
|
1925
|
+
detectCompleteness() { return analysisModule.detectCompleteness(this); }
|
|
2753
1926
|
|
|
2754
|
-
const buildSubgraph = (dir) => {
|
|
2755
|
-
const visited = new Set();
|
|
2756
|
-
const nodes = [];
|
|
2757
|
-
const edges = [];
|
|
2758
1927
|
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
visited.add(file);
|
|
1928
|
+
/** Find related functions — same file, similar names, shared dependencies */
|
|
1929
|
+
related(name, options) { return analysisModule.related(this, name, options); }
|
|
2762
1930
|
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
1931
|
+
/**
|
|
1932
|
+
* Trace call flow - show call tree visualization
|
|
1933
|
+
* This is the "what calls what" command
|
|
1934
|
+
*
|
|
1935
|
+
* @param {string} name - Function name to trace from
|
|
1936
|
+
* @param {object} options - { depth, direction }
|
|
1937
|
+
* @returns {object} Call tree structure
|
|
1938
|
+
*/
|
|
1939
|
+
trace(name, options) { return tracingModule.trace(this, name, options); }
|
|
2766
1940
|
|
|
2767
|
-
|
|
2768
|
-
|
|
1941
|
+
/** Impact analysis — what call sites need updating if a function changes */
|
|
1942
|
+
impact(name, options) { return analysisModule.impact(this, name, options); }
|
|
2769
1943
|
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
1944
|
+
/**
|
|
1945
|
+
* Transitive blast radius — walk UP the caller chain recursively.
|
|
1946
|
+
* Answers: "What breaks transitively if I change this function?"
|
|
1947
|
+
*
|
|
1948
|
+
* @param {string} name - Function name
|
|
1949
|
+
* @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
|
|
1950
|
+
* @returns {object|null} Blast radius tree with summary
|
|
1951
|
+
*/
|
|
1952
|
+
blast(name, options) { return tracingModule.blast(this, name, options); }
|
|
2776
1953
|
|
|
2777
|
-
|
|
2778
|
-
|
|
1954
|
+
/**
|
|
1955
|
+
* Reverse trace: walk UP the caller chain to entry points.
|
|
1956
|
+
* Like blast but focused on "how does execution reach this function?"
|
|
1957
|
+
* Marks leaf nodes (functions with no callers) as entry points.
|
|
1958
|
+
*/
|
|
1959
|
+
reverseTrace(name, options) { return tracingModule.reverseTrace(this, name, options); }
|
|
2779
1960
|
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
1961
|
+
/**
|
|
1962
|
+
* Find tests affected by a change to the given function.
|
|
1963
|
+
* Composes blast() (transitive callers) with test file scanning.
|
|
1964
|
+
*/
|
|
1965
|
+
affectedTests(name, options) { return tracingModule.affectedTests(this, name, options); }
|
|
2785
1966
|
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
};
|
|
1967
|
+
/** Plan a refactoring operation */
|
|
1968
|
+
plan(name, options) { return verifyModule.plan(this, name, options); }
|
|
2789
1969
|
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
return {
|
|
2796
|
-
root: targetPath,
|
|
2797
|
-
direction: 'both',
|
|
2798
|
-
imports: { nodes: importsGraph.nodes, edges: importsGraph.edges },
|
|
2799
|
-
importers: { nodes: importersGraph.nodes, edges: importersGraph.edges },
|
|
2800
|
-
// Keep combined for backward compat
|
|
2801
|
-
nodes: [...importsGraph.nodes, ...importersGraph.nodes.filter(n =>
|
|
2802
|
-
!importsGraph.nodes.some(in_ => in_.file === n.file))],
|
|
2803
|
-
edges: [...importsGraph.edges, ...importersGraph.edges]
|
|
2804
|
-
};
|
|
2805
|
-
}
|
|
1970
|
+
/** Parse a stack trace and show code for each frame */
|
|
1971
|
+
parseStackTrace(stackText) {
|
|
1972
|
+
return stacktrace.parseStackTrace(this, stackText);
|
|
1973
|
+
}
|
|
2806
1974
|
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
direction,
|
|
2811
|
-
nodes: subgraph.nodes,
|
|
2812
|
-
edges: subgraph.edges
|
|
2813
|
-
};
|
|
1975
|
+
/** Calculate path similarity score between two file paths */
|
|
1976
|
+
calculatePathSimilarity(query, candidate) {
|
|
1977
|
+
return stacktrace.calculatePathSimilarity(query, candidate);
|
|
2814
1978
|
}
|
|
2815
1979
|
|
|
2816
|
-
/**
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
* @returns {object} - { cycles, totalFiles, summary }
|
|
2821
|
-
*/
|
|
2822
|
-
circularDeps(options = {}) {
|
|
2823
|
-
this._beginOp();
|
|
2824
|
-
try {
|
|
2825
|
-
const exclude = options.exclude || [];
|
|
2826
|
-
const fileFilter = options.file || null;
|
|
2827
|
-
|
|
2828
|
-
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
2829
|
-
const color = new Map();
|
|
2830
|
-
const cycles = [];
|
|
2831
|
-
const stack = [];
|
|
2832
|
-
|
|
2833
|
-
const shouldSkip = (file) => {
|
|
2834
|
-
if (!this.files.has(file)) return true;
|
|
2835
|
-
if (exclude.length > 0) {
|
|
2836
|
-
const entry = this.files.get(file);
|
|
2837
|
-
if (entry && !this.matchesFilters(entry.relativePath, { exclude })) return true;
|
|
2838
|
-
}
|
|
2839
|
-
return false;
|
|
2840
|
-
};
|
|
1980
|
+
/** Find the best matching file for a stack trace path */
|
|
1981
|
+
findBestMatchingFile(filePath, funcName, lineNum) {
|
|
1982
|
+
return stacktrace.findBestMatchingFile(this, filePath, funcName, lineNum);
|
|
1983
|
+
}
|
|
2841
1984
|
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
1985
|
+
/** Create a stack frame with code context */
|
|
1986
|
+
createStackFrame(filePath, lineNum, funcName, col, rawLine) {
|
|
1987
|
+
return stacktrace.createStackFrame(this, filePath, lineNum, funcName, col, rawLine);
|
|
1988
|
+
}
|
|
2845
1989
|
|
|
2846
|
-
|
|
1990
|
+
/** Verify that all call sites match a function's signature */
|
|
1991
|
+
verify(name, options) { return verifyModule.verify(this, name, options); }
|
|
2847
1992
|
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
const nc = color.get(neighbor) || WHITE;
|
|
2851
|
-
if (nc === GRAY) {
|
|
2852
|
-
const idx = stack.indexOf(neighbor);
|
|
2853
|
-
cycles.push(stack.slice(idx));
|
|
2854
|
-
} else if (nc === WHITE) {
|
|
2855
|
-
dfs(neighbor);
|
|
2856
|
-
}
|
|
2857
|
-
}
|
|
1993
|
+
/** Analyze a call site to understand how it's being called (AST-based) */
|
|
1994
|
+
analyzeCallSite(call, funcName) { return verifyModule.analyzeCallSite(this, call, funcName); }
|
|
2858
1995
|
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
};
|
|
1996
|
+
/** Find a call expression node at the target line matching funcName */
|
|
1997
|
+
_findCallNode(node, callTypes, targetRow, funcName) { return verifyModule.findCallNode(node, callTypes, targetRow, funcName); }
|
|
2862
1998
|
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
dfs(file);
|
|
2866
|
-
}
|
|
2867
|
-
}
|
|
1999
|
+
/** Clear the AST tree cache (call after batch operations) */
|
|
2000
|
+
_clearTreeCache() { verifyModule.clearTreeCache(this); }
|
|
2868
2001
|
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
const uniqueCycles = [];
|
|
2872
|
-
for (const cycle of cycles) {
|
|
2873
|
-
const relCycle = cycle.map(f => this.files.get(f)?.relativePath || path.relative(this.root, f));
|
|
2874
|
-
// Normalize: rotate so lexicographically smallest file is first
|
|
2875
|
-
const sorted = relCycle.slice().sort();
|
|
2876
|
-
const minIdx = relCycle.indexOf(sorted[0]);
|
|
2877
|
-
const rotated = [...relCycle.slice(minIdx), ...relCycle.slice(0, minIdx)];
|
|
2878
|
-
const key = rotated.join('\0');
|
|
2879
|
-
if (!seen.has(key)) {
|
|
2880
|
-
seen.add(key);
|
|
2881
|
-
uniqueCycles.push({ files: rotated, length: rotated.length });
|
|
2882
|
-
}
|
|
2883
|
-
}
|
|
2002
|
+
/** Identify common calling patterns */
|
|
2003
|
+
identifyCallPatterns(callSites, funcName) { return verifyModule.identifyCallPatterns(callSites, funcName); }
|
|
2884
2004
|
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
if (fileFilter) {
|
|
2888
|
-
result = uniqueCycles.filter(c => c.files.some(f => f.includes(fileFilter)));
|
|
2889
|
-
}
|
|
2005
|
+
/** About: comprehensive symbol metadata */
|
|
2006
|
+
about(name, options) { return analysisModule.about(this, name, options); }
|
|
2890
2007
|
|
|
2891
|
-
|
|
2008
|
+
search(term, options) { return searchModule.search(this, term, options); }
|
|
2892
2009
|
|
|
2893
|
-
|
|
2894
|
-
let filesWithImports = 0;
|
|
2895
|
-
for (const [, targets] of this.importGraph) {
|
|
2896
|
-
if (targets && targets.length > 0) filesWithImports++;
|
|
2897
|
-
}
|
|
2010
|
+
structuralSearch(options) { return searchModule.structuralSearch(this, options); }
|
|
2898
2011
|
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
filesWithImports,
|
|
2903
|
-
fileFilter: fileFilter || undefined,
|
|
2904
|
-
summary: {
|
|
2905
|
-
totalCycles: result.length,
|
|
2906
|
-
filesInCycles: new Set(result.flatMap(c => c.files)).size,
|
|
2907
|
-
}
|
|
2908
|
-
};
|
|
2909
|
-
} finally {
|
|
2910
|
-
this._endOp();
|
|
2911
|
-
}
|
|
2912
|
-
}
|
|
2012
|
+
// ========================================================================
|
|
2013
|
+
// PROJECT INFO
|
|
2014
|
+
// ========================================================================
|
|
2913
2015
|
|
|
2914
2016
|
/**
|
|
2915
|
-
*
|
|
2916
|
-
* Returns warnings about dynamic code patterns
|
|
2917
|
-
* Cached to avoid rescanning on every query
|
|
2017
|
+
* Get project statistics
|
|
2918
2018
|
*/
|
|
2919
|
-
|
|
2920
|
-
// Return cached result if available
|
|
2921
|
-
if (this._completenessCache) {
|
|
2922
|
-
return this._completenessCache;
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
const warnings = [];
|
|
2926
|
-
let dynamicImports = 0;
|
|
2927
|
-
let evalUsage = 0;
|
|
2928
|
-
let reflectionUsage = 0;
|
|
2929
|
-
|
|
2930
|
-
const predominantLang = this._getPredominantLanguage();
|
|
2931
|
-
|
|
2932
|
-
for (const [filePath, fileEntry] of this.files) {
|
|
2933
|
-
// Skip node_modules - we don't care about their patterns
|
|
2934
|
-
if (filePath.includes('node_modules')) continue;
|
|
2935
|
-
|
|
2936
|
-
try {
|
|
2937
|
-
const content = this._readFile(filePath);
|
|
2938
|
-
|
|
2939
|
-
if (fileEntry.language !== 'go') {
|
|
2940
|
-
// Dynamic imports: import(), require(variable), __import__
|
|
2941
|
-
dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
|
|
2942
|
-
dynamicImports += (content.match(/require\s*\([^'"]/g) || []).length;
|
|
2943
|
-
dynamicImports += (content.match(/__import__\s*\(/g) || []).length;
|
|
2944
|
-
|
|
2945
|
-
// eval, Function constructor
|
|
2946
|
-
evalUsage += (content.match(/(^|[^a-zA-Z_])eval\s*\(/gm) || []).length;
|
|
2947
|
-
evalUsage += (content.match(/new\s+Function\s*\(/g) || []).length;
|
|
2948
|
-
}
|
|
2949
|
-
|
|
2950
|
-
// Reflection: getattr, hasattr, Reflect
|
|
2951
|
-
reflectionUsage += (content.match(/\bgetattr\s*\(/g) || []).length;
|
|
2952
|
-
reflectionUsage += (content.match(/\bhasattr\s*\(/g) || []).length;
|
|
2953
|
-
reflectionUsage += (content.match(/\bReflect\./g) || []).length;
|
|
2954
|
-
} catch (e) {
|
|
2955
|
-
// Skip unreadable files
|
|
2956
|
-
}
|
|
2957
|
-
}
|
|
2019
|
+
getStats(options) { return reportingModule.getStats(this, options); }
|
|
2958
2020
|
|
|
2959
|
-
|
|
2960
|
-
warnings.push({
|
|
2961
|
-
type: 'dynamic_imports',
|
|
2962
|
-
count: dynamicImports,
|
|
2963
|
-
message: `${dynamicImports} dynamic import(s) detected - some dependencies may be missed`
|
|
2964
|
-
});
|
|
2965
|
-
}
|
|
2021
|
+
getToc(options) { return reportingModule.getToc(this, options); }
|
|
2966
2022
|
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
count: evalUsage,
|
|
2971
|
-
message: `${evalUsage} eval/exec usage(s) detected - dynamically generated code not analyzed`
|
|
2972
|
-
});
|
|
2973
|
-
}
|
|
2023
|
+
// ========================================================================
|
|
2024
|
+
// CACHE METHODS
|
|
2025
|
+
// ========================================================================
|
|
2974
2026
|
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
type: 'reflection',
|
|
2978
|
-
count: reflectionUsage,
|
|
2979
|
-
message: `${reflectionUsage} reflection usage(s) detected - dynamic attribute access not tracked`
|
|
2980
|
-
});
|
|
2981
|
-
}
|
|
2027
|
+
/** Save index to cache file */
|
|
2028
|
+
saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
|
|
2982
2029
|
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
warnings
|
|
2986
|
-
};
|
|
2030
|
+
/** Load index from cache file */
|
|
2031
|
+
loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
|
|
2987
2032
|
|
|
2988
|
-
|
|
2989
|
-
}
|
|
2033
|
+
/** Load callsCache from separate file on demand (called by findCallers/findCallees) */
|
|
2034
|
+
loadCallsCache() { return indexCache.loadCallsCache(this); }
|
|
2990
2035
|
|
|
2036
|
+
/** Check if cache is stale (any files changed or new files added) */
|
|
2037
|
+
isCacheStale() { return indexCache.isCacheStale(this); }
|
|
2991
2038
|
|
|
2992
2039
|
/**
|
|
2993
|
-
* Find
|
|
2994
|
-
*
|
|
2995
|
-
*
|
|
2996
|
-
* @
|
|
2997
|
-
* @returns {object} Related functions grouped by relationship type
|
|
2040
|
+
* Find the best usage example of a function.
|
|
2041
|
+
* Scores call sites using AST analysis (await, destructuring, typed assignment, etc.)
|
|
2042
|
+
* @param {string} name - Symbol name
|
|
2043
|
+
* @returns {{ best: object, totalCalls: number } | null}
|
|
2998
2044
|
*/
|
|
2999
|
-
|
|
3000
|
-
this._beginOp();
|
|
3001
|
-
try {
|
|
3002
|
-
const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
3003
|
-
if (!def) {
|
|
3004
|
-
return null;
|
|
3005
|
-
}
|
|
3006
|
-
const related = {
|
|
3007
|
-
target: {
|
|
3008
|
-
name: def.name,
|
|
3009
|
-
file: def.relativePath,
|
|
3010
|
-
line: def.startLine,
|
|
3011
|
-
type: def.type
|
|
3012
|
-
},
|
|
3013
|
-
sameFile: [],
|
|
3014
|
-
similarNames: [],
|
|
3015
|
-
sharedCallers: [],
|
|
3016
|
-
sharedCallees: []
|
|
3017
|
-
};
|
|
3018
|
-
|
|
3019
|
-
// 1. Same file functions (sorted by proximity to target)
|
|
3020
|
-
const fileEntry = this.files.get(def.file);
|
|
3021
|
-
if (fileEntry) {
|
|
3022
|
-
for (const sym of fileEntry.symbols) {
|
|
3023
|
-
if (sym.name !== name && !NON_CALLABLE_TYPES.has(sym.type)) {
|
|
3024
|
-
related.sameFile.push({
|
|
3025
|
-
name: sym.name,
|
|
3026
|
-
line: sym.startLine,
|
|
3027
|
-
params: sym.params
|
|
3028
|
-
});
|
|
3029
|
-
}
|
|
3030
|
-
}
|
|
3031
|
-
// Sort by distance from target function (nearest first)
|
|
3032
|
-
related.sameFile.sort((a, b) =>
|
|
3033
|
-
Math.abs(a.line - def.startLine) - Math.abs(b.line - def.startLine)
|
|
3034
|
-
);
|
|
3035
|
-
}
|
|
3036
|
-
|
|
3037
|
-
// 2. Similar names (shared prefix/suffix, camelCase similarity)
|
|
3038
|
-
const nameParts = name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
|
|
3039
|
-
for (const [symName, symbols] of this.symbols) {
|
|
3040
|
-
if (symName === name) continue;
|
|
3041
|
-
const symParts = symName.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
|
|
3042
|
-
|
|
3043
|
-
// Check for shared parts (require ≥50% of the longer name to match)
|
|
3044
|
-
const sharedParts = nameParts.filter(p => symParts.includes(p) && p.length > 3);
|
|
3045
|
-
const maxParts = Math.max(nameParts.length, symParts.length);
|
|
3046
|
-
if (sharedParts.length > 0 && sharedParts.length / maxParts >= 0.5) {
|
|
3047
|
-
const sym = symbols[0];
|
|
3048
|
-
related.similarNames.push({
|
|
3049
|
-
name: symName,
|
|
3050
|
-
file: sym.relativePath,
|
|
3051
|
-
line: sym.startLine,
|
|
3052
|
-
sharedParts,
|
|
3053
|
-
type: sym.type
|
|
3054
|
-
});
|
|
3055
|
-
}
|
|
3056
|
-
}
|
|
3057
|
-
// Sort by number of shared parts
|
|
3058
|
-
related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
|
|
3059
|
-
const similarLimit = options.top || (options.all ? Infinity : 10);
|
|
3060
|
-
if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
|
|
3061
|
-
|
|
3062
|
-
// 3. Shared callers - functions called by the same callers
|
|
3063
|
-
const myCallers = new Set(this.findCallers(name).map(c => c.callerName).filter(Boolean));
|
|
3064
|
-
if (myCallers.size > 0) {
|
|
3065
|
-
const callerCounts = new Map();
|
|
3066
|
-
for (const callerName of myCallers) {
|
|
3067
|
-
const callerDef = this.symbols.get(callerName)?.[0];
|
|
3068
|
-
if (callerDef) {
|
|
3069
|
-
const callees = this.findCallees(callerDef);
|
|
3070
|
-
for (const callee of callees) {
|
|
3071
|
-
if (callee.name !== name) {
|
|
3072
|
-
callerCounts.set(callee.name, (callerCounts.get(callee.name) || 0) + 1);
|
|
3073
|
-
}
|
|
3074
|
-
}
|
|
3075
|
-
}
|
|
3076
|
-
}
|
|
3077
|
-
// Sort by shared caller count
|
|
3078
|
-
const maxShared = options.top || (options.all ? Infinity : 5);
|
|
3079
|
-
const sorted = Array.from(callerCounts.entries())
|
|
3080
|
-
.sort((a, b) => b[1] - a[1])
|
|
3081
|
-
.slice(0, maxShared);
|
|
3082
|
-
for (const [symName, count] of sorted) {
|
|
3083
|
-
const sym = this.symbols.get(symName)?.[0];
|
|
3084
|
-
if (sym) {
|
|
3085
|
-
related.sharedCallers.push({
|
|
3086
|
-
name: symName,
|
|
3087
|
-
file: sym.relativePath,
|
|
3088
|
-
line: sym.startLine,
|
|
3089
|
-
sharedCallerCount: count
|
|
3090
|
-
});
|
|
3091
|
-
}
|
|
3092
|
-
}
|
|
3093
|
-
}
|
|
3094
|
-
|
|
3095
|
-
// 4. Shared callees - functions that call the same things
|
|
3096
|
-
// Optimized: instead of computing callees for every symbol (O(N*M)),
|
|
3097
|
-
// find who else calls each of our callees (O(K) where K = our callee count)
|
|
3098
|
-
if (def.type === 'function' || def.params !== undefined) {
|
|
3099
|
-
const myCallees = this.findCallees(def);
|
|
3100
|
-
const myCalleeNames = new Set(myCallees.map(c => c.name));
|
|
3101
|
-
if (myCalleeNames.size > 0) {
|
|
3102
|
-
const calleeCounts = new Map();
|
|
3103
|
-
for (const calleeName of myCalleeNames) {
|
|
3104
|
-
// Find other functions that also call this callee
|
|
3105
|
-
const callers = this.findCallers(calleeName);
|
|
3106
|
-
for (const caller of callers) {
|
|
3107
|
-
if (caller.callerName && caller.callerName !== name) {
|
|
3108
|
-
calleeCounts.set(caller.callerName, (calleeCounts.get(caller.callerName) || 0) + 1);
|
|
3109
|
-
}
|
|
3110
|
-
}
|
|
3111
|
-
}
|
|
3112
|
-
// Sort by shared callee count
|
|
3113
|
-
const sorted = Array.from(calleeCounts.entries())
|
|
3114
|
-
.sort((a, b) => b[1] - a[1])
|
|
3115
|
-
.slice(0, options.top || (options.all ? Infinity : 5));
|
|
3116
|
-
for (const [symName, count] of sorted) {
|
|
3117
|
-
const sym = this.symbols.get(symName)?.[0];
|
|
3118
|
-
if (sym) {
|
|
3119
|
-
related.sharedCallees.push({
|
|
3120
|
-
name: symName,
|
|
3121
|
-
file: sym.relativePath,
|
|
3122
|
-
line: sym.startLine,
|
|
3123
|
-
sharedCalleeCount: count
|
|
3124
|
-
});
|
|
3125
|
-
}
|
|
3126
|
-
}
|
|
3127
|
-
}
|
|
3128
|
-
}
|
|
3129
|
-
|
|
3130
|
-
return related;
|
|
3131
|
-
} finally { this._endOp(); }
|
|
3132
|
-
}
|
|
3133
|
-
|
|
3134
|
-
/**
|
|
3135
|
-
* Trace call flow - show call tree visualization
|
|
3136
|
-
* This is the "what calls what" command
|
|
3137
|
-
*
|
|
3138
|
-
* @param {string} name - Function name to trace from
|
|
3139
|
-
* @param {object} options - { depth, direction }
|
|
3140
|
-
* @returns {object} Call tree structure
|
|
3141
|
-
*/
|
|
3142
|
-
trace(name, options = {}) {
|
|
3143
|
-
this._beginOp();
|
|
3144
|
-
try {
|
|
3145
|
-
// Sanitize depth: use default for null/undefined, clamp negative to 0
|
|
3146
|
-
const rawDepth = options.depth ?? 3;
|
|
3147
|
-
const maxDepth = Math.max(0, rawDepth);
|
|
3148
|
-
const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
|
|
3149
|
-
const maxChildren = options.all ? Infinity : 10;
|
|
3150
|
-
// trace defaults to includeMethods=true (execution flow should show method calls)
|
|
3151
|
-
const includeMethods = options.includeMethods ?? true;
|
|
3152
|
-
|
|
3153
|
-
const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
3154
|
-
if (!def) {
|
|
3155
|
-
return null;
|
|
3156
|
-
}
|
|
3157
|
-
const visited = new Set();
|
|
3158
|
-
const defDir = path.dirname(def.file);
|
|
3159
|
-
|
|
3160
|
-
const buildTree = (funcDef, currentDepth, dir) => {
|
|
3161
|
-
const funcName = funcDef.name;
|
|
3162
|
-
const key = `${funcDef.file}:${funcDef.startLine}`;
|
|
3163
|
-
if (currentDepth > maxDepth) {
|
|
3164
|
-
return null;
|
|
3165
|
-
}
|
|
3166
|
-
if (visited.has(key)) {
|
|
3167
|
-
// Already explored — show as leaf node without recursing (prevents infinite loops)
|
|
3168
|
-
return {
|
|
3169
|
-
name: funcName,
|
|
3170
|
-
file: funcDef.relativePath,
|
|
3171
|
-
line: funcDef.startLine,
|
|
3172
|
-
type: funcDef.type,
|
|
3173
|
-
children: [],
|
|
3174
|
-
alreadyShown: true
|
|
3175
|
-
};
|
|
3176
|
-
}
|
|
3177
|
-
visited.add(key);
|
|
3178
|
-
|
|
3179
|
-
const node = {
|
|
3180
|
-
name: funcName,
|
|
3181
|
-
file: funcDef.relativePath,
|
|
3182
|
-
line: funcDef.startLine,
|
|
3183
|
-
type: funcDef.type,
|
|
3184
|
-
children: []
|
|
3185
|
-
};
|
|
3186
|
-
|
|
3187
|
-
if (dir === 'down' || dir === 'both') {
|
|
3188
|
-
const callees = this.findCallees(funcDef, { includeMethods, includeUncertain: options.includeUncertain });
|
|
3189
|
-
for (const callee of callees.slice(0, maxChildren)) {
|
|
3190
|
-
// callee already has the best-matched definition from findCallees
|
|
3191
|
-
const childTree = buildTree(callee, currentDepth + 1, 'down');
|
|
3192
|
-
if (childTree) {
|
|
3193
|
-
node.children.push({
|
|
3194
|
-
...childTree,
|
|
3195
|
-
callCount: callee.callCount,
|
|
3196
|
-
weight: callee.weight
|
|
3197
|
-
});
|
|
3198
|
-
}
|
|
3199
|
-
}
|
|
3200
|
-
if (callees.length > maxChildren) {
|
|
3201
|
-
node.truncatedChildren = callees.length - maxChildren;
|
|
3202
|
-
}
|
|
3203
|
-
}
|
|
3204
|
-
|
|
3205
|
-
return node;
|
|
3206
|
-
};
|
|
3207
|
-
|
|
3208
|
-
const tree = buildTree(def, 0, direction);
|
|
3209
|
-
|
|
3210
|
-
// Also get callers if direction is 'up' or 'both'
|
|
3211
|
-
let callers = [];
|
|
3212
|
-
let truncatedCallers = 0;
|
|
3213
|
-
if (direction === 'up' || direction === 'both') {
|
|
3214
|
-
const allCallers = this.findCallers(name, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [def] });
|
|
3215
|
-
callers = allCallers.slice(0, maxChildren).map(c => ({
|
|
3216
|
-
name: c.callerName || '(anonymous)',
|
|
3217
|
-
file: c.relativePath,
|
|
3218
|
-
line: c.line,
|
|
3219
|
-
expression: c.content.trim()
|
|
3220
|
-
}));
|
|
3221
|
-
if (allCallers.length > maxChildren) {
|
|
3222
|
-
truncatedCallers = allCallers.length - maxChildren;
|
|
3223
|
-
}
|
|
3224
|
-
}
|
|
3225
|
-
|
|
3226
|
-
// Add smart hint when resolved function has zero callees
|
|
3227
|
-
if (tree && tree.children && tree.children.length === 0) {
|
|
3228
|
-
if (maxDepth === 0) {
|
|
3229
|
-
warnings.push({
|
|
3230
|
-
message: `depth=0: showing root function only. Increase depth to see callees.`
|
|
3231
|
-
});
|
|
3232
|
-
} else if (definitions.length > 1 && !options.file) {
|
|
3233
|
-
warnings.push({
|
|
3234
|
-
message: `Resolved to ${def.relativePath}:${def.startLine} which has no callees. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
|
|
3235
|
-
});
|
|
3236
|
-
}
|
|
3237
|
-
}
|
|
3238
|
-
|
|
3239
|
-
return {
|
|
3240
|
-
root: name,
|
|
3241
|
-
file: def.relativePath,
|
|
3242
|
-
line: def.startLine,
|
|
3243
|
-
direction,
|
|
3244
|
-
maxDepth,
|
|
3245
|
-
includeMethods,
|
|
3246
|
-
tree,
|
|
3247
|
-
callers: direction !== 'down' ? callers : undefined,
|
|
3248
|
-
truncatedCallers: truncatedCallers > 0 ? truncatedCallers : undefined,
|
|
3249
|
-
warnings: warnings.length > 0 ? warnings : undefined
|
|
3250
|
-
};
|
|
3251
|
-
} finally { this._endOp(); }
|
|
3252
|
-
}
|
|
3253
|
-
|
|
3254
|
-
/**
|
|
3255
|
-
* Analyze impact of changing a function - what call sites would need updating
|
|
3256
|
-
* This is the "what breaks if I change this" command
|
|
3257
|
-
*
|
|
3258
|
-
* @param {string} name - Function name
|
|
3259
|
-
* @param {object} options - { groupByFile }
|
|
3260
|
-
* @returns {object} Impact analysis
|
|
3261
|
-
*/
|
|
3262
|
-
impact(name, options = {}) {
|
|
3263
|
-
this._beginOp();
|
|
3264
|
-
try {
|
|
3265
|
-
const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
3266
|
-
if (!def) {
|
|
3267
|
-
return null;
|
|
3268
|
-
}
|
|
3269
|
-
const defIsMethod = def.isMethod || def.type === 'method' || def.className || def.receiver;
|
|
3270
|
-
|
|
3271
|
-
// Use findCallers for className-scoped or method queries (sophisticated binding resolution)
|
|
3272
|
-
// Fall back to usages-based approach for simple function queries (backward compatible)
|
|
3273
|
-
let callSites;
|
|
3274
|
-
if (options.className || defIsMethod) {
|
|
3275
|
-
// findCallers has proper method call resolution (self/this, binding IDs, receiver checks)
|
|
3276
|
-
let callerResults = this.findCallers(name, {
|
|
3277
|
-
includeMethods: true,
|
|
3278
|
-
includeUncertain: false,
|
|
3279
|
-
targetDefinitions: [def],
|
|
3280
|
-
});
|
|
3281
|
-
|
|
3282
|
-
// When the target definition has a className (including Go/Rust methods which
|
|
3283
|
-
// now get className from receiver), filter out method calls whose receiver
|
|
3284
|
-
// clearly belongs to a different type. This helps with common method names
|
|
3285
|
-
// like .close(), .get() etc. where many types have the same method.
|
|
3286
|
-
if (def.className) {
|
|
3287
|
-
const targetClassName = def.className;
|
|
3288
|
-
// Pre-compute how many types share this method name
|
|
3289
|
-
const _impMethodDefs = this.symbols.get(name);
|
|
3290
|
-
const _impClassNames = new Set();
|
|
3291
|
-
if (_impMethodDefs) {
|
|
3292
|
-
for (const d of _impMethodDefs) {
|
|
3293
|
-
if (d.className) _impClassNames.add(d.className);
|
|
3294
|
-
else if (d.receiver) _impClassNames.add(d.receiver.replace(/^\*/, ''));
|
|
3295
|
-
}
|
|
3296
|
-
}
|
|
3297
|
-
callerResults = callerResults.filter(c => {
|
|
3298
|
-
// Keep non-method calls and self/this/cls calls (already resolved by findCallers)
|
|
3299
|
-
if (!c.isMethod) return true;
|
|
3300
|
-
const r = c.receiver;
|
|
3301
|
-
if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
|
|
3302
|
-
// Use receiverType from findCallers when available (Go/Java/Rust type inference)
|
|
3303
|
-
if (c.receiverType) {
|
|
3304
|
-
return c.receiverType === targetClassName;
|
|
3305
|
-
}
|
|
3306
|
-
// No receiver (chained/complex expression): only include if method is
|
|
3307
|
-
// unique or rare across types — otherwise too many false positives
|
|
3308
|
-
if (!r) {
|
|
3309
|
-
return _impClassNames.size <= 1;
|
|
3310
|
-
}
|
|
3311
|
-
// Check if receiver matches the target class name (case-insensitive camelCase convention)
|
|
3312
|
-
if (r.toLowerCase().includes(targetClassName.toLowerCase())) return true;
|
|
3313
|
-
// Check if receiver is an instance of the target class using local variable type inference
|
|
3314
|
-
if (c.callerFile) {
|
|
3315
|
-
const callerDef = c.callerStartLine ? { file: c.callerFile, startLine: c.callerStartLine, endLine: c.callerEndLine } : null;
|
|
3316
|
-
if (callerDef) {
|
|
3317
|
-
const callerCalls = this.getCachedCalls(c.callerFile);
|
|
3318
|
-
if (callerCalls && Array.isArray(callerCalls)) {
|
|
3319
|
-
const localTypes = new Map();
|
|
3320
|
-
for (const call of callerCalls) {
|
|
3321
|
-
if (call.line >= callerDef.startLine && call.line <= callerDef.endLine) {
|
|
3322
|
-
if (!call.isMethod && !call.receiver) {
|
|
3323
|
-
const syms = this.symbols.get(call.name);
|
|
3324
|
-
if (syms && syms.some(s => s.type === 'class')) {
|
|
3325
|
-
// Found a constructor call — check for assignment pattern
|
|
3326
|
-
const fileEntry = this.files.get(c.callerFile);
|
|
3327
|
-
if (fileEntry) {
|
|
3328
|
-
const content = this._readFile(c.callerFile);
|
|
3329
|
-
const lines = content.split('\n');
|
|
3330
|
-
const line = lines[call.line - 1] || '';
|
|
3331
|
-
// Match "var = ClassName(...)" or "var = new ClassName(...)" or "Type var = new ClassName<>(...)"
|
|
3332
|
-
const m = line.match(/(\w+)\s*=\s*(?:await\s+)?(?:new\s+)?(\w+)\s*(?:<[^>]*>)?\s*\(/);
|
|
3333
|
-
if (m && m[2] === call.name) {
|
|
3334
|
-
localTypes.set(m[1], call.name);
|
|
3335
|
-
}
|
|
3336
|
-
}
|
|
3337
|
-
}
|
|
3338
|
-
}
|
|
3339
|
-
}
|
|
3340
|
-
}
|
|
3341
|
-
const receiverType = localTypes.get(r);
|
|
3342
|
-
if (receiverType) {
|
|
3343
|
-
return receiverType === targetClassName;
|
|
3344
|
-
}
|
|
3345
|
-
}
|
|
3346
|
-
}
|
|
3347
|
-
}
|
|
3348
|
-
// Check class field declarations for receiver type: private DataService service
|
|
3349
|
-
if (c.callerFile) {
|
|
3350
|
-
const callerEnclosing = this.findEnclosingFunction(c.callerFile, c.line, true);
|
|
3351
|
-
if (callerEnclosing?.className) {
|
|
3352
|
-
const classSyms = this.symbols.get(callerEnclosing.className);
|
|
3353
|
-
if (classSyms) {
|
|
3354
|
-
const classDef = classSyms.find(s => s.type === 'class' || s.type === 'struct' || s.type === 'interface');
|
|
3355
|
-
if (classDef) {
|
|
3356
|
-
const content = this._readFile(c.callerFile);
|
|
3357
|
-
const lines = content.split('\n');
|
|
3358
|
-
// Scan class body for field declarations matching the receiver
|
|
3359
|
-
for (let li = classDef.startLine - 1; li < (classDef.endLine || classDef.startLine + 50) && li < lines.length; li++) {
|
|
3360
|
-
const line = lines[li];
|
|
3361
|
-
// Match Java/TS field: [modifiers] TypeName<...> receiverName [= ...]
|
|
3362
|
-
const fieldMatch = line.match(new RegExp(`\\b(\\w+)(?:<[^>]*>)?\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}\\s*[;=]`));
|
|
3363
|
-
if (fieldMatch) {
|
|
3364
|
-
const fieldType = fieldMatch[1];
|
|
3365
|
-
if (fieldType === targetClassName) return true;
|
|
3366
|
-
break;
|
|
3367
|
-
}
|
|
3368
|
-
}
|
|
3369
|
-
}
|
|
3370
|
-
}
|
|
3371
|
-
}
|
|
3372
|
-
}
|
|
3373
|
-
// Check parameter type annotations: def foo(tracker: SourceTracker) → tracker.record()
|
|
3374
|
-
if (c.callerFile && c.callerStartLine) {
|
|
3375
|
-
const callerSymbol = this.findEnclosingFunction(c.callerFile, c.line, true);
|
|
3376
|
-
if (callerSymbol && callerSymbol.paramsStructured) {
|
|
3377
|
-
for (const param of callerSymbol.paramsStructured) {
|
|
3378
|
-
if (param.name === r && param.type) {
|
|
3379
|
-
// Check if the type annotation contains the target class name
|
|
3380
|
-
const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
|
|
3381
|
-
if (typeMatches && typeMatches.some(t => t === targetClassName)) {
|
|
3382
|
-
return true;
|
|
3383
|
-
}
|
|
3384
|
-
// Type annotation exists but doesn't match target class — filter out
|
|
3385
|
-
return false;
|
|
3386
|
-
}
|
|
3387
|
-
}
|
|
3388
|
-
}
|
|
3389
|
-
}
|
|
3390
|
-
// Unique method heuristic: if the called method exists on exactly one class/type
|
|
3391
|
-
// and it matches the target, include the call (no other class could match)
|
|
3392
|
-
if (_impClassNames.size === 1 && _impClassNames.has(targetClassName)) {
|
|
3393
|
-
return true;
|
|
3394
|
-
}
|
|
3395
|
-
// Type-scoped query but receiver type unknown — filter it out.
|
|
3396
|
-
// Unknown receivers are likely unrelated.
|
|
3397
|
-
return false;
|
|
3398
|
-
});
|
|
3399
|
-
}
|
|
3400
|
-
|
|
3401
|
-
callSites = [];
|
|
3402
|
-
for (const c of callerResults) {
|
|
3403
|
-
const analysis = this.analyzeCallSite(
|
|
3404
|
-
{ file: c.file, relativePath: c.relativePath, line: c.line, content: c.content },
|
|
3405
|
-
name
|
|
3406
|
-
);
|
|
3407
|
-
callSites.push({
|
|
3408
|
-
file: c.relativePath,
|
|
3409
|
-
line: c.line,
|
|
3410
|
-
expression: c.content.trim(),
|
|
3411
|
-
callerName: c.callerName,
|
|
3412
|
-
...analysis
|
|
3413
|
-
});
|
|
3414
|
-
}
|
|
3415
|
-
this._clearTreeCache();
|
|
3416
|
-
} else {
|
|
3417
|
-
// Use findCallers (benefits from callee index) instead of usages() for speed
|
|
3418
|
-
const callerResults = this.findCallers(name, {
|
|
3419
|
-
includeMethods: false,
|
|
3420
|
-
includeUncertain: false,
|
|
3421
|
-
targetDefinitions: [def],
|
|
3422
|
-
});
|
|
3423
|
-
const targetBindingId = def.bindingId;
|
|
3424
|
-
// Convert findCallers results to the format expected by analyzeCallSite
|
|
3425
|
-
const calls = callerResults.map(c => ({
|
|
3426
|
-
file: c.file,
|
|
3427
|
-
relativePath: c.relativePath,
|
|
3428
|
-
line: c.line,
|
|
3429
|
-
content: c.content,
|
|
3430
|
-
usageType: 'call',
|
|
3431
|
-
callerName: c.callerName,
|
|
3432
|
-
}));
|
|
3433
|
-
// Keep the same binding filter for backward compat (findCallers already handles this,
|
|
3434
|
-
// but cross-check with usages-based binding filter for safety)
|
|
3435
|
-
const filteredCalls = calls.filter(u => {
|
|
3436
|
-
const fileEntry = this.files.get(u.file);
|
|
3437
|
-
if (fileEntry && targetBindingId) {
|
|
3438
|
-
let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
|
|
3439
|
-
if (localBindings.length === 0 && fileEntry.language === 'go') {
|
|
3440
|
-
const dir = path.dirname(u.file);
|
|
3441
|
-
for (const [fp, fe] of this.files) {
|
|
3442
|
-
if (fp !== u.file && path.dirname(fp) === dir) {
|
|
3443
|
-
const sibling = (fe.bindings || []).filter(b => b.name === name);
|
|
3444
|
-
localBindings = localBindings.concat(sibling);
|
|
3445
|
-
}
|
|
3446
|
-
}
|
|
3447
|
-
}
|
|
3448
|
-
if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
|
|
3449
|
-
return false;
|
|
3450
|
-
}
|
|
3451
|
-
}
|
|
3452
|
-
return true;
|
|
3453
|
-
});
|
|
3454
|
-
// (findCallers already handles binding resolution and scope-aware filtering)
|
|
3455
|
-
|
|
3456
|
-
// Analyze each call site, filtering out method calls for non-method definitions
|
|
3457
|
-
callSites = [];
|
|
3458
|
-
const defFileEntry = this.files.get(def.file);
|
|
3459
|
-
const defLang = defFileEntry?.language;
|
|
3460
|
-
const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
|
|
3461
|
-
for (const call of filteredCalls) {
|
|
3462
|
-
const analysis = this.analyzeCallSite(call, name);
|
|
3463
|
-
// Skip method calls (obj.parse()) when target is a standalone function (parse())
|
|
3464
|
-
// For Go, allow calls where receiver matches the package directory name
|
|
3465
|
-
// (e.g., controller.FilterActive() where file is in pkg/controller/)
|
|
3466
|
-
if (analysis.isMethodCall && !defIsMethod) {
|
|
3467
|
-
if (targetDir) {
|
|
3468
|
-
// Get receiver from parsed calls cache
|
|
3469
|
-
const parsedCalls = this.getCachedCalls(call.file);
|
|
3470
|
-
const matchedCall = parsedCalls?.find(c => c.name === name && c.line === call.line);
|
|
3471
|
-
if (matchedCall?.receiver === targetDir) {
|
|
3472
|
-
// Receiver matches package directory — keep it
|
|
3473
|
-
} else {
|
|
3474
|
-
continue;
|
|
3475
|
-
}
|
|
3476
|
-
} else {
|
|
3477
|
-
continue;
|
|
3478
|
-
}
|
|
3479
|
-
}
|
|
3480
|
-
callSites.push({
|
|
3481
|
-
file: call.relativePath,
|
|
3482
|
-
line: call.line,
|
|
3483
|
-
expression: call.content.trim(),
|
|
3484
|
-
callerName: call.callerName || this.findEnclosingFunction(call.file, call.line),
|
|
3485
|
-
...analysis
|
|
3486
|
-
});
|
|
3487
|
-
}
|
|
3488
|
-
this._clearTreeCache();
|
|
3489
|
-
}
|
|
3490
|
-
|
|
3491
|
-
// Apply exclude filter
|
|
3492
|
-
let filteredSites = callSites;
|
|
3493
|
-
if (options.exclude && options.exclude.length > 0) {
|
|
3494
|
-
filteredSites = callSites.filter(s => this.matchesFilters(s.file, { exclude: options.exclude }));
|
|
3495
|
-
}
|
|
3496
|
-
|
|
3497
|
-
// Apply top limit if specified (limits total call sites shown)
|
|
3498
|
-
const totalBeforeLimit = filteredSites.length;
|
|
3499
|
-
if (options.top && options.top > 0 && filteredSites.length > options.top) {
|
|
3500
|
-
filteredSites = filteredSites.slice(0, options.top);
|
|
3501
|
-
}
|
|
3502
|
-
|
|
3503
|
-
// Group by file
|
|
3504
|
-
const byFile = new Map();
|
|
3505
|
-
for (const site of filteredSites) {
|
|
3506
|
-
if (!byFile.has(site.file)) {
|
|
3507
|
-
byFile.set(site.file, []);
|
|
3508
|
-
}
|
|
3509
|
-
byFile.get(site.file).push(site);
|
|
3510
|
-
}
|
|
3511
|
-
|
|
3512
|
-
// Identify patterns
|
|
3513
|
-
const patterns = this.identifyCallPatterns(filteredSites, name);
|
|
3514
|
-
|
|
3515
|
-
// Detect scope pollution: multiple class definitions for the same method name
|
|
3516
|
-
let scopeWarning = null;
|
|
3517
|
-
if (defIsMethod) {
|
|
3518
|
-
const allDefs = this.symbols.get(name);
|
|
3519
|
-
if (allDefs && allDefs.length > 1) {
|
|
3520
|
-
const classNames = [...new Set(allDefs
|
|
3521
|
-
.filter(d => d.className && d.className !== def.className)
|
|
3522
|
-
.map(d => d.className))];
|
|
3523
|
-
if (classNames.length > 0 && !options.className && !options.file) {
|
|
3524
|
-
scopeWarning = {
|
|
3525
|
-
targetClass: def.className || '(unknown)',
|
|
3526
|
-
otherClasses: classNames,
|
|
3527
|
-
hint: `Results may include calls to ${classNames.join(', ')}.${name}(). Use file= or className= to narrow scope.`
|
|
3528
|
-
};
|
|
3529
|
-
}
|
|
3530
|
-
}
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
|
-
return {
|
|
3534
|
-
function: name,
|
|
3535
|
-
file: def.relativePath,
|
|
3536
|
-
startLine: def.startLine,
|
|
3537
|
-
signature: this.formatSignature(def),
|
|
3538
|
-
params: def.params,
|
|
3539
|
-
paramsStructured: def.paramsStructured,
|
|
3540
|
-
totalCallSites: totalBeforeLimit,
|
|
3541
|
-
shownCallSites: filteredSites.length,
|
|
3542
|
-
byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
|
|
3543
|
-
file,
|
|
3544
|
-
count: sites.length,
|
|
3545
|
-
sites
|
|
3546
|
-
})),
|
|
3547
|
-
patterns,
|
|
3548
|
-
scopeWarning
|
|
3549
|
-
};
|
|
3550
|
-
} finally { this._endOp(); }
|
|
3551
|
-
}
|
|
3552
|
-
|
|
3553
|
-
/**
|
|
3554
|
-
* Transitive blast radius — walk UP the caller chain recursively.
|
|
3555
|
-
* Answers: "What breaks transitively if I change this function?"
|
|
3556
|
-
*
|
|
3557
|
-
* @param {string} name - Function name
|
|
3558
|
-
* @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
|
|
3559
|
-
* @returns {object|null} Blast radius tree with summary
|
|
3560
|
-
*/
|
|
3561
|
-
blast(name, options = {}) {
|
|
3562
|
-
this._beginOp();
|
|
3563
|
-
try {
|
|
3564
|
-
const maxDepth = Math.max(0, options.depth ?? 3);
|
|
3565
|
-
const maxChildren = options.all ? Infinity : 10;
|
|
3566
|
-
const includeMethods = options.includeMethods ?? true;
|
|
3567
|
-
const includeUncertain = options.includeUncertain || false;
|
|
3568
|
-
const exclude = options.exclude || [];
|
|
3569
|
-
|
|
3570
|
-
const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
3571
|
-
if (!def) return null;
|
|
3572
|
-
|
|
3573
|
-
const visited = new Set();
|
|
3574
|
-
const affectedFunctions = new Set();
|
|
3575
|
-
const affectedFiles = new Set();
|
|
3576
|
-
let maxDepthReached = 0;
|
|
3577
|
-
|
|
3578
|
-
const buildCallerTree = (funcDef, currentDepth) => {
|
|
3579
|
-
const key = `${funcDef.file}:${funcDef.startLine}`;
|
|
3580
|
-
if (currentDepth > maxDepth) return null;
|
|
3581
|
-
if (visited.has(key)) {
|
|
3582
|
-
return {
|
|
3583
|
-
name: funcDef.name,
|
|
3584
|
-
file: funcDef.relativePath,
|
|
3585
|
-
line: funcDef.startLine,
|
|
3586
|
-
type: funcDef.type || 'function',
|
|
3587
|
-
children: [],
|
|
3588
|
-
alreadyShown: true
|
|
3589
|
-
};
|
|
3590
|
-
}
|
|
3591
|
-
visited.add(key);
|
|
3592
|
-
|
|
3593
|
-
if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
|
|
3594
|
-
if (currentDepth > 0) {
|
|
3595
|
-
affectedFunctions.add(key);
|
|
3596
|
-
affectedFiles.add(funcDef.file);
|
|
3597
|
-
}
|
|
3598
|
-
|
|
3599
|
-
const node = {
|
|
3600
|
-
name: funcDef.name,
|
|
3601
|
-
file: funcDef.relativePath,
|
|
3602
|
-
line: funcDef.startLine,
|
|
3603
|
-
type: funcDef.type || 'function',
|
|
3604
|
-
children: []
|
|
3605
|
-
};
|
|
3606
|
-
|
|
3607
|
-
if (currentDepth < maxDepth) {
|
|
3608
|
-
const callers = this.findCallers(funcDef.name, {
|
|
3609
|
-
includeMethods,
|
|
3610
|
-
includeUncertain,
|
|
3611
|
-
targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
|
|
3612
|
-
});
|
|
3613
|
-
|
|
3614
|
-
// Deduplicate callers by enclosing function (multiple call sites → one tree node)
|
|
3615
|
-
const uniqueCallers = new Map();
|
|
3616
|
-
for (const c of callers) {
|
|
3617
|
-
if (!c.callerName) continue; // skip module-level code
|
|
3618
|
-
// Apply exclude filter
|
|
3619
|
-
if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
|
|
3620
|
-
const callerKey = c.callerStartLine
|
|
3621
|
-
? `${c.callerFile}:${c.callerStartLine}`
|
|
3622
|
-
: `${c.callerFile}:${c.callerName}`;
|
|
3623
|
-
if (!uniqueCallers.has(callerKey)) {
|
|
3624
|
-
uniqueCallers.set(callerKey, {
|
|
3625
|
-
name: c.callerName,
|
|
3626
|
-
file: c.callerFile,
|
|
3627
|
-
relativePath: c.relativePath,
|
|
3628
|
-
startLine: c.callerStartLine,
|
|
3629
|
-
endLine: c.callerEndLine,
|
|
3630
|
-
callSites: 1
|
|
3631
|
-
});
|
|
3632
|
-
} else {
|
|
3633
|
-
uniqueCallers.get(callerKey).callSites++;
|
|
3634
|
-
}
|
|
3635
|
-
}
|
|
3636
|
-
|
|
3637
|
-
// Resolve definitions and build child nodes
|
|
3638
|
-
const callerEntries = [];
|
|
3639
|
-
for (const [, caller] of uniqueCallers) {
|
|
3640
|
-
// Look up actual definition from symbol table
|
|
3641
|
-
const defs = this.symbols.get(caller.name);
|
|
3642
|
-
let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
|
|
3643
|
-
|
|
3644
|
-
if (!callerDef) {
|
|
3645
|
-
// Pseudo-definition for callers not in symbol table
|
|
3646
|
-
callerDef = {
|
|
3647
|
-
name: caller.name,
|
|
3648
|
-
file: caller.file,
|
|
3649
|
-
relativePath: caller.relativePath,
|
|
3650
|
-
startLine: caller.startLine,
|
|
3651
|
-
endLine: caller.endLine,
|
|
3652
|
-
type: 'function'
|
|
3653
|
-
};
|
|
3654
|
-
}
|
|
3655
|
-
|
|
3656
|
-
callerEntries.push({ def: callerDef, callSites: caller.callSites });
|
|
3657
|
-
}
|
|
3658
|
-
|
|
3659
|
-
// Stable sort by file + line
|
|
3660
|
-
callerEntries.sort((a, b) =>
|
|
3661
|
-
a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
|
|
3662
|
-
);
|
|
3663
|
-
|
|
3664
|
-
for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
|
|
3665
|
-
const childTree = buildCallerTree(cDef, currentDepth + 1);
|
|
3666
|
-
if (childTree) {
|
|
3667
|
-
childTree.callSites = callSites;
|
|
3668
|
-
node.children.push(childTree);
|
|
3669
|
-
}
|
|
3670
|
-
}
|
|
3671
|
-
|
|
3672
|
-
if (callerEntries.length > maxChildren) {
|
|
3673
|
-
node.truncatedChildren = callerEntries.length - maxChildren;
|
|
3674
|
-
// Count truncated callers in summary
|
|
3675
|
-
for (const { def: cDef } of callerEntries.slice(maxChildren)) {
|
|
3676
|
-
const key = `${cDef.file}:${cDef.startLine}`;
|
|
3677
|
-
if (!visited.has(key)) {
|
|
3678
|
-
affectedFunctions.add(key);
|
|
3679
|
-
affectedFiles.add(cDef.file);
|
|
3680
|
-
}
|
|
3681
|
-
}
|
|
3682
|
-
}
|
|
3683
|
-
}
|
|
3684
|
-
|
|
3685
|
-
return node;
|
|
3686
|
-
};
|
|
3687
|
-
|
|
3688
|
-
const tree = buildCallerTree(def, 0);
|
|
3689
|
-
|
|
3690
|
-
// Smart hints
|
|
3691
|
-
if (tree && tree.children.length === 0) {
|
|
3692
|
-
if (maxDepth === 0) {
|
|
3693
|
-
warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
|
|
3694
|
-
} else if (definitions.length > 1 && !options.file) {
|
|
3695
|
-
warnings.push({
|
|
3696
|
-
message: `Resolved to ${def.relativePath}:${def.startLine} which has no callers. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
|
|
3697
|
-
});
|
|
3698
|
-
}
|
|
3699
|
-
}
|
|
3700
|
-
|
|
3701
|
-
return {
|
|
3702
|
-
root: name,
|
|
3703
|
-
file: def.relativePath,
|
|
3704
|
-
line: def.startLine,
|
|
3705
|
-
maxDepth,
|
|
3706
|
-
includeMethods,
|
|
3707
|
-
tree,
|
|
3708
|
-
summary: {
|
|
3709
|
-
totalAffected: affectedFunctions.size,
|
|
3710
|
-
totalFiles: affectedFiles.size,
|
|
3711
|
-
maxDepthReached
|
|
3712
|
-
},
|
|
3713
|
-
warnings: warnings.length > 0 ? warnings : undefined
|
|
3714
|
-
};
|
|
3715
|
-
} finally { this._endOp(); }
|
|
3716
|
-
}
|
|
3717
|
-
|
|
3718
|
-
/**
|
|
3719
|
-
* Reverse trace: walk UP the caller chain to entry points.
|
|
3720
|
-
* Like blast but focused on "how does execution reach this function?"
|
|
3721
|
-
* Marks leaf nodes (functions with no callers) as entry points.
|
|
3722
|
-
*/
|
|
3723
|
-
reverseTrace(name, options = {}) {
|
|
3724
|
-
this._beginOp();
|
|
3725
|
-
try {
|
|
3726
|
-
const maxDepth = Math.max(0, options.depth ?? 5);
|
|
3727
|
-
const maxChildren = options.all ? Infinity : 10;
|
|
3728
|
-
const includeMethods = options.includeMethods ?? true;
|
|
3729
|
-
const includeUncertain = options.includeUncertain || false;
|
|
3730
|
-
const exclude = options.exclude || [];
|
|
3731
|
-
|
|
3732
|
-
const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
3733
|
-
if (!def) return null;
|
|
3734
|
-
|
|
3735
|
-
const visited = new Set();
|
|
3736
|
-
const entryPoints = [];
|
|
3737
|
-
let maxDepthReached = 0;
|
|
3738
|
-
|
|
3739
|
-
const buildCallerTree = (funcDef, currentDepth) => {
|
|
3740
|
-
const key = `${funcDef.file}:${funcDef.startLine}`;
|
|
3741
|
-
if (currentDepth > maxDepth) return null;
|
|
3742
|
-
if (visited.has(key)) {
|
|
3743
|
-
return {
|
|
3744
|
-
name: funcDef.name,
|
|
3745
|
-
file: funcDef.relativePath,
|
|
3746
|
-
line: funcDef.startLine,
|
|
3747
|
-
type: funcDef.type || 'function',
|
|
3748
|
-
children: [],
|
|
3749
|
-
alreadyShown: true
|
|
3750
|
-
};
|
|
3751
|
-
}
|
|
3752
|
-
visited.add(key);
|
|
3753
|
-
if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
|
|
3754
|
-
|
|
3755
|
-
const node = {
|
|
3756
|
-
name: funcDef.name,
|
|
3757
|
-
file: funcDef.relativePath,
|
|
3758
|
-
line: funcDef.startLine,
|
|
3759
|
-
type: funcDef.type || 'function',
|
|
3760
|
-
children: []
|
|
3761
|
-
};
|
|
3762
|
-
|
|
3763
|
-
if (currentDepth < maxDepth) {
|
|
3764
|
-
const callers = this.findCallers(funcDef.name, {
|
|
3765
|
-
includeMethods,
|
|
3766
|
-
includeUncertain,
|
|
3767
|
-
targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
|
|
3768
|
-
});
|
|
3769
|
-
|
|
3770
|
-
// Deduplicate callers by enclosing function
|
|
3771
|
-
const uniqueCallers = new Map();
|
|
3772
|
-
for (const c of callers) {
|
|
3773
|
-
if (!c.callerName) continue;
|
|
3774
|
-
if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
|
|
3775
|
-
const callerKey = c.callerStartLine
|
|
3776
|
-
? `${c.callerFile}:${c.callerStartLine}`
|
|
3777
|
-
: `${c.callerFile}:${c.callerName}`;
|
|
3778
|
-
if (!uniqueCallers.has(callerKey)) {
|
|
3779
|
-
uniqueCallers.set(callerKey, {
|
|
3780
|
-
name: c.callerName,
|
|
3781
|
-
file: c.callerFile,
|
|
3782
|
-
relativePath: c.relativePath,
|
|
3783
|
-
startLine: c.callerStartLine,
|
|
3784
|
-
endLine: c.callerEndLine,
|
|
3785
|
-
callSites: 1
|
|
3786
|
-
});
|
|
3787
|
-
} else {
|
|
3788
|
-
uniqueCallers.get(callerKey).callSites++;
|
|
3789
|
-
}
|
|
3790
|
-
}
|
|
3791
|
-
|
|
3792
|
-
// Resolve definitions and build child nodes
|
|
3793
|
-
const callerEntries = [];
|
|
3794
|
-
for (const [, caller] of uniqueCallers) {
|
|
3795
|
-
const defs = this.symbols.get(caller.name);
|
|
3796
|
-
let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
|
|
3797
|
-
if (!callerDef) {
|
|
3798
|
-
callerDef = {
|
|
3799
|
-
name: caller.name,
|
|
3800
|
-
file: caller.file,
|
|
3801
|
-
relativePath: caller.relativePath,
|
|
3802
|
-
startLine: caller.startLine,
|
|
3803
|
-
endLine: caller.endLine,
|
|
3804
|
-
type: 'function'
|
|
3805
|
-
};
|
|
3806
|
-
}
|
|
3807
|
-
callerEntries.push({ def: callerDef, callSites: caller.callSites });
|
|
3808
|
-
}
|
|
3809
|
-
|
|
3810
|
-
callerEntries.sort((a, b) =>
|
|
3811
|
-
a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
|
|
3812
|
-
);
|
|
3813
|
-
|
|
3814
|
-
for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
|
|
3815
|
-
const childTree = buildCallerTree(cDef, currentDepth + 1);
|
|
3816
|
-
if (childTree) {
|
|
3817
|
-
childTree.callSites = callSites;
|
|
3818
|
-
node.children.push(childTree);
|
|
3819
|
-
}
|
|
3820
|
-
}
|
|
3821
|
-
|
|
3822
|
-
if (callerEntries.length > maxChildren) {
|
|
3823
|
-
node.truncatedChildren = callerEntries.length - maxChildren;
|
|
3824
|
-
// Count entry points in truncated branches so summary is accurate
|
|
3825
|
-
for (const { def: cDef } of callerEntries.slice(maxChildren)) {
|
|
3826
|
-
const key = `${cDef.file}:${cDef.startLine}`;
|
|
3827
|
-
if (!visited.has(key)) {
|
|
3828
|
-
const cCallers = this.findCallers(cDef.name, {
|
|
3829
|
-
includeMethods, includeUncertain,
|
|
3830
|
-
targetDefinitions: cDef.bindingId ? [cDef] : undefined,
|
|
3831
|
-
});
|
|
3832
|
-
if (cCallers.length === 0) {
|
|
3833
|
-
entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(this.root, cDef.file), line: cDef.startLine });
|
|
3834
|
-
}
|
|
3835
|
-
}
|
|
3836
|
-
}
|
|
3837
|
-
}
|
|
3838
|
-
|
|
3839
|
-
// Mark as entry point if no callers found (and not at depth limit)
|
|
3840
|
-
if (uniqueCallers.size === 0 && currentDepth > 0) {
|
|
3841
|
-
node.entryPoint = true;
|
|
3842
|
-
entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
|
|
3843
|
-
}
|
|
3844
|
-
} else if (currentDepth > 0) {
|
|
3845
|
-
// At depth limit: check if this node is an entry point
|
|
3846
|
-
const callers = this.findCallers(funcDef.name, {
|
|
3847
|
-
includeMethods,
|
|
3848
|
-
includeUncertain,
|
|
3849
|
-
targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
|
|
3850
|
-
});
|
|
3851
|
-
const hasCallers = callers.some(c => c.callerName &&
|
|
3852
|
-
(exclude.length === 0 || this.matchesFilters(c.relativePath, { exclude })));
|
|
3853
|
-
if (!hasCallers) {
|
|
3854
|
-
node.entryPoint = true;
|
|
3855
|
-
entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
|
|
3856
|
-
}
|
|
3857
|
-
}
|
|
3858
|
-
|
|
3859
|
-
return node;
|
|
3860
|
-
};
|
|
3861
|
-
|
|
3862
|
-
const tree = buildCallerTree(def, 0);
|
|
3863
|
-
|
|
3864
|
-
// Also mark root as entry point if it has no callers
|
|
3865
|
-
if (tree && tree.children.length === 0 && maxDepth > 0) {
|
|
3866
|
-
tree.entryPoint = true;
|
|
3867
|
-
entryPoints.push({ name: def.name, file: def.relativePath, line: def.startLine });
|
|
3868
|
-
}
|
|
3869
|
-
|
|
3870
|
-
// Smart hints
|
|
3871
|
-
if (tree && tree.children.length === 0) {
|
|
3872
|
-
if (maxDepth === 0) {
|
|
3873
|
-
warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
|
|
3874
|
-
} else if (definitions.length > 1 && !options.file) {
|
|
3875
|
-
warnings.push({
|
|
3876
|
-
message: `Resolved to ${def.relativePath}:${def.startLine} which has no callers. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
|
|
3877
|
-
});
|
|
3878
|
-
}
|
|
3879
|
-
}
|
|
3880
|
-
|
|
3881
|
-
return {
|
|
3882
|
-
root: name,
|
|
3883
|
-
file: def.relativePath,
|
|
3884
|
-
line: def.startLine,
|
|
3885
|
-
maxDepth,
|
|
3886
|
-
includeMethods,
|
|
3887
|
-
tree,
|
|
3888
|
-
entryPoints,
|
|
3889
|
-
summary: {
|
|
3890
|
-
totalEntryPoints: entryPoints.length,
|
|
3891
|
-
totalFunctions: visited.size - 1, // exclude root
|
|
3892
|
-
maxDepthReached
|
|
3893
|
-
},
|
|
3894
|
-
warnings: warnings.length > 0 ? warnings : undefined
|
|
3895
|
-
};
|
|
3896
|
-
} finally { this._endOp(); }
|
|
3897
|
-
}
|
|
3898
|
-
|
|
3899
|
-
/**
|
|
3900
|
-
* Find tests affected by a change to the given function.
|
|
3901
|
-
* Composes blast() (transitive callers) with test file scanning.
|
|
3902
|
-
*/
|
|
3903
|
-
affectedTests(name, options = {}) {
|
|
3904
|
-
this._beginOp();
|
|
3905
|
-
try {
|
|
3906
|
-
// Step 1: Get all transitively affected functions via blast
|
|
3907
|
-
const blastResult = this.blast(name, {
|
|
3908
|
-
depth: options.depth ?? 3,
|
|
3909
|
-
file: options.file,
|
|
3910
|
-
className: options.className,
|
|
3911
|
-
all: true,
|
|
3912
|
-
exclude: options.exclude,
|
|
3913
|
-
includeMethods: options.includeMethods,
|
|
3914
|
-
includeUncertain: options.includeUncertain,
|
|
3915
|
-
});
|
|
3916
|
-
if (!blastResult) return null;
|
|
3917
|
-
|
|
3918
|
-
// Step 2: Collect all affected function names from the tree
|
|
3919
|
-
const affectedNames = new Set();
|
|
3920
|
-
affectedNames.add(name);
|
|
3921
|
-
const collectNames = (node) => {
|
|
3922
|
-
if (!node) return;
|
|
3923
|
-
affectedNames.add(node.name);
|
|
3924
|
-
for (const child of node.children || []) collectNames(child);
|
|
3925
|
-
};
|
|
3926
|
-
collectNames(blastResult.tree);
|
|
3927
|
-
|
|
3928
|
-
// Step 3: Build regex patterns for all names
|
|
3929
|
-
const namePatterns = new Map();
|
|
3930
|
-
for (const n of affectedNames) {
|
|
3931
|
-
const escaped = escapeRegExp(n);
|
|
3932
|
-
namePatterns.set(n, {
|
|
3933
|
-
regex: new RegExp('\\b' + escaped + '\\b'),
|
|
3934
|
-
callPattern: new RegExp(escaped + '\\s*\\('),
|
|
3935
|
-
});
|
|
3936
|
-
}
|
|
3937
|
-
|
|
3938
|
-
// Step 4: Scan test files once for all affected names
|
|
3939
|
-
const exclude = options.exclude;
|
|
3940
|
-
const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
|
|
3941
|
-
const results = [];
|
|
3942
|
-
for (const [filePath, fileEntry] of this.files) {
|
|
3943
|
-
let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
|
|
3944
|
-
// Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
|
|
3945
|
-
if (!isTest && fileEntry.language === 'rust') {
|
|
3946
|
-
isTest = fileEntry.symbols?.some(s => s.modifiers?.includes('test'));
|
|
3947
|
-
}
|
|
3948
|
-
if (!isTest) continue;
|
|
3949
|
-
if (excludeArr.length > 0 && !this.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
|
|
3950
|
-
try {
|
|
3951
|
-
const content = this._readFile(filePath);
|
|
3952
|
-
const lines = content.split('\n');
|
|
3953
|
-
const fileMatches = new Map();
|
|
3954
|
-
|
|
3955
|
-
lines.forEach((line, idx) => {
|
|
3956
|
-
for (const [funcName, patterns] of namePatterns) {
|
|
3957
|
-
if (patterns.regex.test(line)) {
|
|
3958
|
-
let matchType = 'reference';
|
|
3959
|
-
if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
|
|
3960
|
-
matchType = 'test-case';
|
|
3961
|
-
} else if (/\b(import|require|from)\b/.test(line)) {
|
|
3962
|
-
matchType = 'import';
|
|
3963
|
-
} else if (patterns.callPattern.test(line)) {
|
|
3964
|
-
matchType = 'call';
|
|
3965
|
-
}
|
|
3966
|
-
if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
|
|
3967
|
-
fileMatches.get(funcName).push({
|
|
3968
|
-
line: idx + 1, content: line.trim(),
|
|
3969
|
-
matchType, functionName: funcName
|
|
3970
|
-
});
|
|
3971
|
-
}
|
|
3972
|
-
}
|
|
3973
|
-
});
|
|
3974
|
-
|
|
3975
|
-
if (fileMatches.size > 0) {
|
|
3976
|
-
const coveredFunctions = [...fileMatches.keys()];
|
|
3977
|
-
const allMatches = [];
|
|
3978
|
-
for (const matches of fileMatches.values()) allMatches.push(...matches);
|
|
3979
|
-
allMatches.sort((a, b) => a.line - b.line);
|
|
3980
|
-
results.push({
|
|
3981
|
-
file: fileEntry.relativePath,
|
|
3982
|
-
coveredFunctions,
|
|
3983
|
-
matchCount: allMatches.length,
|
|
3984
|
-
matches: allMatches
|
|
3985
|
-
});
|
|
3986
|
-
}
|
|
3987
|
-
} catch (e) { /* skip unreadable */ }
|
|
3988
|
-
}
|
|
3989
|
-
|
|
3990
|
-
// Sort by coverage breadth then alphabetically
|
|
3991
|
-
results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
|
|
3992
|
-
|
|
3993
|
-
// Compute coverage stats
|
|
3994
|
-
const coveredSet = new Set();
|
|
3995
|
-
for (const r of results) for (const f of r.coveredFunctions) coveredSet.add(f);
|
|
3996
|
-
const uncovered = [...affectedNames].filter(n => !coveredSet.has(n));
|
|
3997
|
-
|
|
3998
|
-
return {
|
|
3999
|
-
root: blastResult.root, file: blastResult.file, line: blastResult.line,
|
|
4000
|
-
depth: blastResult.maxDepth,
|
|
4001
|
-
affectedFunctions: [...affectedNames],
|
|
4002
|
-
testFiles: results,
|
|
4003
|
-
summary: {
|
|
4004
|
-
totalAffected: affectedNames.size,
|
|
4005
|
-
totalTestFiles: results.length,
|
|
4006
|
-
coveredFunctions: coveredSet.size,
|
|
4007
|
-
uncoveredCount: uncovered.length,
|
|
4008
|
-
},
|
|
4009
|
-
uncovered,
|
|
4010
|
-
warnings: blastResult.warnings,
|
|
4011
|
-
};
|
|
4012
|
-
} finally { this._endOp(); }
|
|
4013
|
-
}
|
|
4014
|
-
|
|
4015
|
-
/** Plan a refactoring operation */
|
|
4016
|
-
plan(name, options) { return verifyModule.plan(this, name, options); }
|
|
4017
|
-
|
|
4018
|
-
/** Parse a stack trace and show code for each frame */
|
|
4019
|
-
parseStackTrace(stackText) {
|
|
4020
|
-
return stacktrace.parseStackTrace(this, stackText);
|
|
4021
|
-
}
|
|
4022
|
-
|
|
4023
|
-
/** Calculate path similarity score between two file paths */
|
|
4024
|
-
calculatePathSimilarity(query, candidate) {
|
|
4025
|
-
return stacktrace.calculatePathSimilarity(query, candidate);
|
|
4026
|
-
}
|
|
4027
|
-
|
|
4028
|
-
/** Find the best matching file for a stack trace path */
|
|
4029
|
-
findBestMatchingFile(filePath, funcName, lineNum) {
|
|
4030
|
-
return stacktrace.findBestMatchingFile(this, filePath, funcName, lineNum);
|
|
4031
|
-
}
|
|
4032
|
-
|
|
4033
|
-
/** Create a stack frame with code context */
|
|
4034
|
-
createStackFrame(filePath, lineNum, funcName, col, rawLine) {
|
|
4035
|
-
return stacktrace.createStackFrame(this, filePath, lineNum, funcName, col, rawLine);
|
|
4036
|
-
}
|
|
4037
|
-
|
|
4038
|
-
/** Verify that all call sites match a function's signature */
|
|
4039
|
-
verify(name, options) { return verifyModule.verify(this, name, options); }
|
|
4040
|
-
|
|
4041
|
-
/** Analyze a call site to understand how it's being called (AST-based) */
|
|
4042
|
-
analyzeCallSite(call, funcName) { return verifyModule.analyzeCallSite(this, call, funcName); }
|
|
4043
|
-
|
|
4044
|
-
/** Find a call expression node at the target line matching funcName */
|
|
4045
|
-
_findCallNode(node, callTypes, targetRow, funcName) { return verifyModule.findCallNode(node, callTypes, targetRow, funcName); }
|
|
4046
|
-
|
|
4047
|
-
/** Clear the AST tree cache (call after batch operations) */
|
|
4048
|
-
_clearTreeCache() { verifyModule.clearTreeCache(this); }
|
|
4049
|
-
|
|
4050
|
-
/** Identify common calling patterns */
|
|
4051
|
-
identifyCallPatterns(callSites, funcName) { return verifyModule.identifyCallPatterns(callSites, funcName); }
|
|
4052
|
-
|
|
4053
|
-
/**
|
|
4054
|
-
* Get complete information about a symbol - definition, usages, callers, callees, tests, code
|
|
4055
|
-
* This is the "tell me everything" command for AI agents
|
|
4056
|
-
*
|
|
4057
|
-
* @param {string} name - Symbol name
|
|
4058
|
-
* @param {object} options - { maxCallers, maxCallees, withCode, withTypes }
|
|
4059
|
-
* @returns {object} Complete symbol info
|
|
4060
|
-
*/
|
|
4061
|
-
about(name, options = {}) {
|
|
4062
|
-
this._beginOp();
|
|
4063
|
-
try {
|
|
4064
|
-
const maxCallers = options.all ? Infinity : (options.maxCallers || 10);
|
|
4065
|
-
const maxCallees = options.all ? Infinity : (options.maxCallees || 10);
|
|
4066
|
-
|
|
4067
|
-
// Find symbol definition(s) — skip counts since about() computes its own via usages()
|
|
4068
|
-
const definitions = this.find(name, { exact: true, file: options.file, className: options.className, skipCounts: true });
|
|
4069
|
-
if (definitions.length === 0) {
|
|
4070
|
-
// Try fuzzy match (needs counts for suggestion ranking)
|
|
4071
|
-
const fuzzy = this.find(name, { file: options.file, className: options.className });
|
|
4072
|
-
if (fuzzy.length === 0) {
|
|
4073
|
-
return null;
|
|
4074
|
-
}
|
|
4075
|
-
// Return suggestion
|
|
4076
|
-
return {
|
|
4077
|
-
found: false,
|
|
4078
|
-
suggestions: (options.all ? fuzzy : fuzzy.slice(0, 5)).map(s => ({
|
|
4079
|
-
name: s.name,
|
|
4080
|
-
file: s.relativePath,
|
|
4081
|
-
line: s.startLine,
|
|
4082
|
-
type: s.type,
|
|
4083
|
-
usageCount: s.usageCount
|
|
4084
|
-
}))
|
|
4085
|
-
};
|
|
4086
|
-
}
|
|
4087
|
-
|
|
4088
|
-
// Use resolveSymbol for consistent primary selection (prefers non-test files)
|
|
4089
|
-
const { def: resolved } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
4090
|
-
const primary = resolved || definitions[0];
|
|
4091
|
-
const others = definitions.filter(d =>
|
|
4092
|
-
d.relativePath !== primary.relativePath || d.startLine !== primary.startLine
|
|
4093
|
-
);
|
|
4094
|
-
|
|
4095
|
-
// Use the actual symbol name (may differ from query if fuzzy matched)
|
|
4096
|
-
const symbolName = primary.name;
|
|
4097
|
-
|
|
4098
|
-
// Default includeMethods: true when target is a class method (method calls are the primary way
|
|
4099
|
-
// class methods are invoked), false for standalone functions (reduces noise from unrelated obj.fn() calls)
|
|
4100
|
-
const isMethod = !!(primary.isMethod || primary.type === 'method' || primary.className);
|
|
4101
|
-
const includeMethods = options.includeMethods ?? isMethod;
|
|
4102
|
-
|
|
4103
|
-
// Get usage counts by type (fast path uses callee index, no file reads)
|
|
4104
|
-
// Exclude test files by default (matching usages command behavior)
|
|
4105
|
-
const countExclude = !options.includeTests ? addTestExclusions(options.exclude) : options.exclude;
|
|
4106
|
-
const usagesByType = this.countSymbolUsages(primary, { exclude: countExclude });
|
|
4107
|
-
|
|
4108
|
-
// Get callers and callees (only for functions)
|
|
4109
|
-
let callers = [];
|
|
4110
|
-
let callees = [];
|
|
4111
|
-
let allCallers = null;
|
|
4112
|
-
let allCallees = null;
|
|
4113
|
-
let aboutConfFiltered = 0;
|
|
4114
|
-
if (primary.type === 'function' || primary.params !== undefined) {
|
|
4115
|
-
// Use maxResults to limit file iteration (with buffer for exclude filtering)
|
|
4116
|
-
const callerCap = maxCallers === Infinity ? undefined : maxCallers * 3;
|
|
4117
|
-
allCallers = this.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
|
|
4118
|
-
// Apply exclude filter before slicing
|
|
4119
|
-
if (options.exclude && options.exclude.length > 0) {
|
|
4120
|
-
allCallers = allCallers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
4121
|
-
}
|
|
4122
|
-
// Apply confidence filtering before slicing
|
|
4123
|
-
if (options.minConfidence > 0) {
|
|
4124
|
-
const { filterByConfidence } = require('./confidence');
|
|
4125
|
-
const callerResult = filterByConfidence(allCallers, options.minConfidence);
|
|
4126
|
-
allCallers = callerResult.kept;
|
|
4127
|
-
aboutConfFiltered += callerResult.filtered;
|
|
4128
|
-
}
|
|
4129
|
-
callers = allCallers.slice(0, maxCallers).map(c => ({
|
|
4130
|
-
file: c.relativePath,
|
|
4131
|
-
line: c.line,
|
|
4132
|
-
expression: c.content.trim(),
|
|
4133
|
-
callerName: c.callerName,
|
|
4134
|
-
confidence: c.confidence,
|
|
4135
|
-
resolution: c.resolution,
|
|
4136
|
-
}));
|
|
4137
|
-
|
|
4138
|
-
allCallees = this.findCallees(primary, { includeMethods, includeUncertain: options.includeUncertain });
|
|
4139
|
-
// Apply exclude filter before slicing
|
|
4140
|
-
if (options.exclude && options.exclude.length > 0) {
|
|
4141
|
-
allCallees = allCallees.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
4142
|
-
}
|
|
4143
|
-
// Apply confidence filtering before slicing
|
|
4144
|
-
if (options.minConfidence > 0) {
|
|
4145
|
-
const { filterByConfidence } = require('./confidence');
|
|
4146
|
-
const calleeResult = filterByConfidence(allCallees, options.minConfidence);
|
|
4147
|
-
allCallees = calleeResult.kept;
|
|
4148
|
-
aboutConfFiltered += calleeResult.filtered;
|
|
4149
|
-
}
|
|
4150
|
-
callees = allCallees.slice(0, maxCallees).map(c => ({
|
|
4151
|
-
name: c.name,
|
|
4152
|
-
file: c.relativePath,
|
|
4153
|
-
line: c.startLine,
|
|
4154
|
-
startLine: c.startLine,
|
|
4155
|
-
endLine: c.endLine,
|
|
4156
|
-
weight: c.weight,
|
|
4157
|
-
callCount: c.callCount,
|
|
4158
|
-
confidence: c.confidence,
|
|
4159
|
-
resolution: c.resolution,
|
|
4160
|
-
}));
|
|
4161
|
-
}
|
|
4162
|
-
|
|
4163
|
-
// Find tests
|
|
4164
|
-
const tests = this.tests(symbolName);
|
|
4165
|
-
const testSummary = {
|
|
4166
|
-
fileCount: tests.length,
|
|
4167
|
-
totalMatches: tests.reduce((sum, t) => sum + t.matches.length, 0),
|
|
4168
|
-
files: (options.all ? tests : tests.slice(0, 3)).map(t => t.file)
|
|
4169
|
-
};
|
|
4170
|
-
|
|
4171
|
-
// Extract code if requested (default: true)
|
|
4172
|
-
let code = null;
|
|
4173
|
-
if (options.withCode !== false) {
|
|
4174
|
-
code = this.extractCode(primary);
|
|
4175
|
-
}
|
|
4176
|
-
|
|
4177
|
-
// Get type definitions if requested
|
|
4178
|
-
let types = [];
|
|
4179
|
-
if (options.withTypes) {
|
|
4180
|
-
const TYPE_KINDS = ['type', 'interface', 'class', 'struct'];
|
|
4181
|
-
const seen = new Set();
|
|
4182
|
-
|
|
4183
|
-
const addType = (typeName) => {
|
|
4184
|
-
if (seen.has(typeName)) return;
|
|
4185
|
-
seen.add(typeName);
|
|
4186
|
-
const typeSymbols = this.symbols.get(typeName);
|
|
4187
|
-
if (typeSymbols) {
|
|
4188
|
-
for (const sym of typeSymbols) {
|
|
4189
|
-
if (TYPE_KINDS.includes(sym.type)) {
|
|
4190
|
-
types.push({
|
|
4191
|
-
name: sym.name,
|
|
4192
|
-
type: sym.type,
|
|
4193
|
-
file: sym.relativePath,
|
|
4194
|
-
line: sym.startLine
|
|
4195
|
-
});
|
|
4196
|
-
}
|
|
4197
|
-
}
|
|
4198
|
-
}
|
|
4199
|
-
};
|
|
4200
|
-
|
|
4201
|
-
// From signature annotations
|
|
4202
|
-
const typeNames = this.extractTypeNames(primary);
|
|
4203
|
-
for (const typeName of typeNames) addType(typeName);
|
|
4204
|
-
|
|
4205
|
-
// From callee signatures — types used by functions this function calls
|
|
4206
|
-
if (allCallees) {
|
|
4207
|
-
for (const callee of allCallees) {
|
|
4208
|
-
const calleeTypeNames = this.extractTypeNames(callee);
|
|
4209
|
-
for (const tn of calleeTypeNames) addType(tn);
|
|
4210
|
-
}
|
|
4211
|
-
}
|
|
4212
|
-
}
|
|
4213
|
-
|
|
4214
|
-
const result = {
|
|
4215
|
-
found: true,
|
|
4216
|
-
symbol: {
|
|
4217
|
-
name: primary.name,
|
|
4218
|
-
type: primary.type,
|
|
4219
|
-
file: primary.relativePath,
|
|
4220
|
-
startLine: primary.startLine,
|
|
4221
|
-
endLine: primary.endLine,
|
|
4222
|
-
params: primary.params,
|
|
4223
|
-
returnType: primary.returnType,
|
|
4224
|
-
modifiers: primary.modifiers,
|
|
4225
|
-
docstring: primary.docstring,
|
|
4226
|
-
signature: this.formatSignature(primary)
|
|
4227
|
-
},
|
|
4228
|
-
usages: usagesByType,
|
|
4229
|
-
totalUsages: usagesByType.calls + usagesByType.imports + usagesByType.references,
|
|
4230
|
-
callers: {
|
|
4231
|
-
total: allCallers?.length ?? 0,
|
|
4232
|
-
top: callers
|
|
4233
|
-
},
|
|
4234
|
-
callees: {
|
|
4235
|
-
total: allCallees?.length ?? 0,
|
|
4236
|
-
top: callees
|
|
4237
|
-
},
|
|
4238
|
-
tests: testSummary,
|
|
4239
|
-
otherDefinitions: (options.all ? others : others.slice(0, 3)).map(d => ({
|
|
4240
|
-
file: d.relativePath,
|
|
4241
|
-
line: d.startLine,
|
|
4242
|
-
usageCount: d.usageCount ?? this.countSymbolUsages(d).total
|
|
4243
|
-
})),
|
|
4244
|
-
types,
|
|
4245
|
-
code,
|
|
4246
|
-
includeMethods,
|
|
4247
|
-
...(aboutConfFiltered > 0 && { confidenceFiltered: aboutConfFiltered }),
|
|
4248
|
-
completeness: this.detectCompleteness()
|
|
4249
|
-
};
|
|
4250
|
-
|
|
4251
|
-
return result;
|
|
4252
|
-
} finally { this._endOp(); }
|
|
4253
|
-
}
|
|
4254
|
-
|
|
4255
|
-
/**
|
|
4256
|
-
* Search for text across the project
|
|
4257
|
-
* @param {string} term - Search term
|
|
4258
|
-
* @param {object} options - { codeOnly, context, caseSensitive, exclude, in }
|
|
4259
|
-
*/
|
|
4260
|
-
search(term, options = {}) {
|
|
4261
|
-
this._beginOp();
|
|
4262
|
-
try {
|
|
4263
|
-
const results = [];
|
|
4264
|
-
let filesScanned = 0;
|
|
4265
|
-
let filesSkipped = 0;
|
|
4266
|
-
let filesFilteredByFlag = 0;
|
|
4267
|
-
const regexFlags = options.caseSensitive ? 'g' : 'gi';
|
|
4268
|
-
const useRegex = options.regex !== false; // Default: regex ON
|
|
4269
|
-
let regex;
|
|
4270
|
-
let regexFallback = false;
|
|
4271
|
-
if (useRegex) {
|
|
4272
|
-
try {
|
|
4273
|
-
regex = new RegExp(term, regexFlags);
|
|
4274
|
-
} catch (e) {
|
|
4275
|
-
// Invalid regex — fall back to plain text
|
|
4276
|
-
regex = new RegExp(escapeRegExp(term), regexFlags);
|
|
4277
|
-
regexFallback = e.message;
|
|
4278
|
-
}
|
|
4279
|
-
} else {
|
|
4280
|
-
regex = new RegExp(escapeRegExp(term), regexFlags);
|
|
4281
|
-
}
|
|
4282
|
-
|
|
4283
|
-
for (const [filePath, fileEntry] of this.files) {
|
|
4284
|
-
// Apply --file filter
|
|
4285
|
-
if (options.file) {
|
|
4286
|
-
const fp = fileEntry.relativePath;
|
|
4287
|
-
if (!fp.includes(options.file) && !fp.endsWith(options.file)) {
|
|
4288
|
-
filesFilteredByFlag++;
|
|
4289
|
-
continue;
|
|
4290
|
-
}
|
|
4291
|
-
}
|
|
4292
|
-
// Apply exclude/in filters
|
|
4293
|
-
if ((options.exclude && options.exclude.length > 0) || options.in) {
|
|
4294
|
-
if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) {
|
|
4295
|
-
filesSkipped++;
|
|
4296
|
-
continue;
|
|
4297
|
-
}
|
|
4298
|
-
}
|
|
4299
|
-
filesScanned++;
|
|
4300
|
-
try {
|
|
4301
|
-
const content = this._readFile(filePath);
|
|
4302
|
-
const lines = content.split('\n');
|
|
4303
|
-
const matches = [];
|
|
4304
|
-
|
|
4305
|
-
// Use AST-based filtering for codeOnly mode when language is supported
|
|
4306
|
-
if (options.codeOnly) {
|
|
4307
|
-
const language = detectLanguage(filePath);
|
|
4308
|
-
if (language) {
|
|
4309
|
-
try {
|
|
4310
|
-
const parser = getParser(language);
|
|
4311
|
-
const { findMatchesWithASTFilter } = require('../languages/utils');
|
|
4312
|
-
const astMatches = findMatchesWithASTFilter(content, term, parser, { codeOnly: true, regex: useRegex });
|
|
4313
|
-
|
|
4314
|
-
for (const m of astMatches) {
|
|
4315
|
-
const match = {
|
|
4316
|
-
line: m.line,
|
|
4317
|
-
content: m.content
|
|
4318
|
-
};
|
|
4319
|
-
|
|
4320
|
-
// Add context lines if requested
|
|
4321
|
-
if (options.context && options.context > 0) {
|
|
4322
|
-
const idx = m.line - 1;
|
|
4323
|
-
const before = [];
|
|
4324
|
-
const after = [];
|
|
4325
|
-
for (let i = 1; i <= options.context; i++) {
|
|
4326
|
-
if (idx - i >= 0) before.unshift(lines[idx - i]);
|
|
4327
|
-
if (idx + i < lines.length) after.push(lines[idx + i]);
|
|
4328
|
-
}
|
|
4329
|
-
match.before = before;
|
|
4330
|
-
match.after = after;
|
|
4331
|
-
}
|
|
4332
|
-
|
|
4333
|
-
matches.push(match);
|
|
4334
|
-
}
|
|
4335
|
-
|
|
4336
|
-
if (matches.length > 0) {
|
|
4337
|
-
results.push({
|
|
4338
|
-
file: fileEntry.relativePath,
|
|
4339
|
-
matches
|
|
4340
|
-
});
|
|
4341
|
-
}
|
|
4342
|
-
continue; // Skip to next file
|
|
4343
|
-
} catch (e) {
|
|
4344
|
-
// Fall through to regex-based search
|
|
4345
|
-
}
|
|
4346
|
-
}
|
|
4347
|
-
}
|
|
4348
|
-
|
|
4349
|
-
// Fallback to regex-based search (non-codeOnly or unsupported language)
|
|
4350
|
-
lines.forEach((line, idx) => {
|
|
4351
|
-
regex.lastIndex = 0; // Reset regex state
|
|
4352
|
-
if (regex.test(line)) {
|
|
4353
|
-
const lineNum = idx + 1;
|
|
4354
|
-
// Skip if codeOnly and line is comment/string
|
|
4355
|
-
if (options.codeOnly && this.isCommentOrStringAtPosition(content, lineNum, 0, filePath)) {
|
|
4356
|
-
return;
|
|
4357
|
-
}
|
|
4358
|
-
|
|
4359
|
-
const match = {
|
|
4360
|
-
line: idx + 1,
|
|
4361
|
-
content: line
|
|
4362
|
-
};
|
|
4363
|
-
|
|
4364
|
-
// Add context lines if requested
|
|
4365
|
-
if (options.context && options.context > 0) {
|
|
4366
|
-
const before = [];
|
|
4367
|
-
const after = [];
|
|
4368
|
-
for (let i = 1; i <= options.context; i++) {
|
|
4369
|
-
if (idx - i >= 0) before.unshift(lines[idx - i]);
|
|
4370
|
-
if (idx + i < lines.length) after.push(lines[idx + i]);
|
|
4371
|
-
}
|
|
4372
|
-
match.before = before;
|
|
4373
|
-
match.after = after;
|
|
4374
|
-
}
|
|
4375
|
-
|
|
4376
|
-
matches.push(match);
|
|
4377
|
-
}
|
|
4378
|
-
});
|
|
4379
|
-
|
|
4380
|
-
if (matches.length > 0) {
|
|
4381
|
-
results.push({
|
|
4382
|
-
file: fileEntry.relativePath,
|
|
4383
|
-
matches
|
|
4384
|
-
});
|
|
4385
|
-
}
|
|
4386
|
-
} catch (e) {
|
|
4387
|
-
// Expected: binary/minified files fail to read or parse.
|
|
4388
|
-
// These are not actionable errors — silently skip.
|
|
4389
|
-
}
|
|
4390
|
-
}
|
|
4391
|
-
|
|
4392
|
-
// Apply top limit (limits total matches across all files)
|
|
4393
|
-
const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
|
|
4394
|
-
let truncatedMatches = 0;
|
|
4395
|
-
if (options.top && options.top > 0 && totalMatches > options.top) {
|
|
4396
|
-
let remaining = options.top;
|
|
4397
|
-
const truncated = [];
|
|
4398
|
-
for (const r of results) {
|
|
4399
|
-
if (remaining <= 0) break;
|
|
4400
|
-
if (r.matches.length <= remaining) {
|
|
4401
|
-
truncated.push(r);
|
|
4402
|
-
remaining -= r.matches.length;
|
|
4403
|
-
} else {
|
|
4404
|
-
truncated.push({ ...r, matches: r.matches.slice(0, remaining) });
|
|
4405
|
-
remaining = 0;
|
|
4406
|
-
}
|
|
4407
|
-
}
|
|
4408
|
-
truncatedMatches = totalMatches - options.top;
|
|
4409
|
-
results.length = 0;
|
|
4410
|
-
results.push(...truncated);
|
|
4411
|
-
}
|
|
4412
|
-
|
|
4413
|
-
results.meta = { filesScanned, filesSkipped, filesFilteredByFlag, totalFiles: this.files.size, regexFallback, totalMatches, truncatedMatches };
|
|
4414
|
-
return results;
|
|
4415
|
-
} finally { this._endOp(); }
|
|
4416
|
-
}
|
|
4417
|
-
|
|
4418
|
-
/**
|
|
4419
|
-
* Structural search — query the symbol table and call index, not raw text.
|
|
4420
|
-
* Answers questions like "functions taking Request param", "all db.* calls",
|
|
4421
|
-
* "exported async functions", "decorated route handlers".
|
|
4422
|
-
*
|
|
4423
|
-
* @param {object} options
|
|
4424
|
-
* @param {string} [options.term] - Name filter (glob: * and ? supported)
|
|
4425
|
-
* @param {string} [options.type] - Symbol kind: function, class, call, method, type
|
|
4426
|
-
* @param {string} [options.param] - Parameter name or type substring
|
|
4427
|
-
* @param {string} [options.receiver] - Call receiver pattern (for type=call)
|
|
4428
|
-
* @param {string} [options.returns] - Return type substring
|
|
4429
|
-
* @param {string} [options.decorator] - Decorator/annotation name substring
|
|
4430
|
-
* @param {boolean} [options.exported] - Only exported symbols
|
|
4431
|
-
* @param {boolean} [options.unused] - Only symbols with zero callers
|
|
4432
|
-
* @param {string[]} [options.exclude] - Exclude file patterns
|
|
4433
|
-
* @param {string} [options.in] - Restrict to subdirectory
|
|
4434
|
-
* @param {string} [options.file] - File pattern filter
|
|
4435
|
-
* @param {number} [options.top] - Limit results
|
|
4436
|
-
* @returns {{ results: Array, meta: object }}
|
|
4437
|
-
*/
|
|
4438
|
-
structuralSearch(options = {}) {
|
|
4439
|
-
this._beginOp();
|
|
4440
|
-
try {
|
|
4441
|
-
const { term, param, receiver, returns: returnType, decorator, exported, unused } = options;
|
|
4442
|
-
// Auto-infer type: --receiver implies type=call
|
|
4443
|
-
const type = options.type || (receiver ? 'call' : undefined);
|
|
4444
|
-
const results = [];
|
|
4445
|
-
|
|
4446
|
-
// Validate type if provided
|
|
4447
|
-
if (type && !STRUCTURAL_TYPES.has(type)) {
|
|
4448
|
-
return {
|
|
4449
|
-
results: [],
|
|
4450
|
-
meta: {
|
|
4451
|
-
mode: 'structural',
|
|
4452
|
-
query: { type },
|
|
4453
|
-
totalMatched: 0,
|
|
4454
|
-
shown: 0,
|
|
4455
|
-
error: `Invalid type "${type}". Valid types: ${[...STRUCTURAL_TYPES].join(', ')}`,
|
|
4456
|
-
}
|
|
4457
|
-
};
|
|
4458
|
-
}
|
|
4459
|
-
|
|
4460
|
-
// Build glob-style name matcher from term
|
|
4461
|
-
const nameMatcher = term ? buildGlobMatcher(term, options.caseSensitive) : null;
|
|
4462
|
-
|
|
4463
|
-
// Helper: check if file passes filters
|
|
4464
|
-
const passesFileFilter = (fileEntry) => {
|
|
4465
|
-
if (!fileEntry) return false;
|
|
4466
|
-
if (options.file) {
|
|
4467
|
-
const rp = fileEntry.relativePath;
|
|
4468
|
-
if (!rp.includes(options.file) && !rp.endsWith(options.file)) return false;
|
|
4469
|
-
}
|
|
4470
|
-
if ((options.exclude && options.exclude.length > 0) || options.in) {
|
|
4471
|
-
if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) return false;
|
|
4472
|
-
}
|
|
4473
|
-
return true;
|
|
4474
|
-
};
|
|
4475
|
-
|
|
4476
|
-
if (type === 'call') {
|
|
4477
|
-
// Search call sites from callee index
|
|
4478
|
-
const { getCachedCalls } = require('./callers');
|
|
4479
|
-
const seenFiles = new Set();
|
|
4480
|
-
|
|
4481
|
-
// If term is given, only scan files that might contain that call
|
|
4482
|
-
if (term && !term.includes('*') && !term.includes('?')) {
|
|
4483
|
-
// Exact or substring — use callee index for fast lookup
|
|
4484
|
-
this.buildCalleeIndex();
|
|
4485
|
-
const files = this.calleeIndex.get(term);
|
|
4486
|
-
if (files) for (const f of files) seenFiles.add(f);
|
|
4487
|
-
} else {
|
|
4488
|
-
// Scan all files
|
|
4489
|
-
for (const fp of this.files.keys()) seenFiles.add(fp);
|
|
4490
|
-
}
|
|
4491
|
-
|
|
4492
|
-
for (const filePath of seenFiles) {
|
|
4493
|
-
const fileEntry = this.files.get(filePath);
|
|
4494
|
-
if (!passesFileFilter(fileEntry)) continue;
|
|
4495
|
-
const calls = getCachedCalls(this, filePath);
|
|
4496
|
-
if (!calls) continue;
|
|
4497
|
-
for (const call of calls) {
|
|
4498
|
-
if (nameMatcher && !nameMatcher(call.name)) continue;
|
|
4499
|
-
if (receiver) {
|
|
4500
|
-
if (!call.receiver) continue;
|
|
4501
|
-
if (!matchesSubstring(call.receiver, receiver, options.caseSensitive)) continue;
|
|
4502
|
-
}
|
|
4503
|
-
results.push({
|
|
4504
|
-
kind: 'call',
|
|
4505
|
-
name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
|
|
4506
|
-
file: fileEntry.relativePath,
|
|
4507
|
-
line: call.line,
|
|
4508
|
-
receiver: call.receiver || null,
|
|
4509
|
-
isMethod: call.isMethod || false,
|
|
4510
|
-
});
|
|
4511
|
-
}
|
|
4512
|
-
}
|
|
4513
|
-
} else {
|
|
4514
|
-
// Search symbols (functions, classes, methods, types)
|
|
4515
|
-
const functionTypes = new Set(['function', 'constructor', 'method', 'arrow', 'static', 'classmethod', 'abstract']);
|
|
4516
|
-
const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait']);
|
|
4517
|
-
const typeTypes = new Set(['type', 'enum', 'interface', 'trait']);
|
|
4518
|
-
const methodTypes = new Set(['method', 'constructor']);
|
|
4519
|
-
|
|
4520
|
-
for (const [symbolName, definitions] of this.symbols) {
|
|
4521
|
-
if (nameMatcher && !nameMatcher(symbolName)) continue;
|
|
4522
|
-
|
|
4523
|
-
for (const def of definitions) {
|
|
4524
|
-
// Type filter
|
|
4525
|
-
if (type === 'function' && !functionTypes.has(def.type)) continue;
|
|
4526
|
-
if (type === 'class' && !classTypes.has(def.type)) continue;
|
|
4527
|
-
if (type === 'method' && !methodTypes.has(def.type) && !def.isMethod) continue;
|
|
4528
|
-
if (type === 'type' && !typeTypes.has(def.type)) continue;
|
|
4529
|
-
|
|
4530
|
-
// File filters
|
|
4531
|
-
const fileEntry = this.files.get(def.file);
|
|
4532
|
-
if (!passesFileFilter(fileEntry)) continue;
|
|
4533
|
-
|
|
4534
|
-
// Param filter: match param name or type
|
|
4535
|
-
if (param) {
|
|
4536
|
-
const cs = options.caseSensitive;
|
|
4537
|
-
const ps = def.paramsStructured || [];
|
|
4538
|
-
const paramStr = def.params || '';
|
|
4539
|
-
const hasMatch = ps.some(p =>
|
|
4540
|
-
matchesSubstring(p.name, param, cs) ||
|
|
4541
|
-
(p.type && matchesSubstring(p.type, param, cs))
|
|
4542
|
-
) || matchesSubstring(paramStr, param, cs);
|
|
4543
|
-
if (!hasMatch) continue;
|
|
4544
|
-
}
|
|
4545
|
-
|
|
4546
|
-
// Receiver filter: match className for methods
|
|
4547
|
-
if (receiver) {
|
|
4548
|
-
if (!def.className || !matchesSubstring(def.className, receiver, options.caseSensitive)) continue;
|
|
4549
|
-
}
|
|
4550
|
-
|
|
4551
|
-
// Return type filter
|
|
4552
|
-
if (returnType) {
|
|
4553
|
-
if (!def.returnType || !matchesSubstring(def.returnType, returnType, options.caseSensitive)) continue;
|
|
4554
|
-
}
|
|
4555
|
-
|
|
4556
|
-
// Decorator filter: checks decorators (Python), modifiers (Java annotations stored lowercase)
|
|
4557
|
-
if (decorator) {
|
|
4558
|
-
const cs = options.caseSensitive;
|
|
4559
|
-
const hasDecorator = (def.decorators && def.decorators.some(d => matchesSubstring(d, decorator, cs))) ||
|
|
4560
|
-
(def.modifiers && def.modifiers.some(m => matchesSubstring(m, decorator, cs)));
|
|
4561
|
-
if (!hasDecorator) continue;
|
|
4562
|
-
}
|
|
4563
|
-
|
|
4564
|
-
// Exported filter
|
|
4565
|
-
if (exported) {
|
|
4566
|
-
const mods = def.modifiers || [];
|
|
4567
|
-
const isExp = (fileEntry && fileEntry.exports.includes(symbolName)) ||
|
|
4568
|
-
mods.includes('export') || mods.includes('public') ||
|
|
4569
|
-
mods.some(m => m.startsWith('pub')) ||
|
|
4570
|
-
(fileEntry && fileEntry.language === 'go' && /^[A-Z]/.test(symbolName));
|
|
4571
|
-
if (!isExp) continue;
|
|
4572
|
-
}
|
|
4573
|
-
|
|
4574
|
-
// Unused filter (expensive — last check)
|
|
4575
|
-
if (unused) {
|
|
4576
|
-
this.buildCalleeIndex();
|
|
4577
|
-
if (this.calleeIndex.has(symbolName)) continue;
|
|
4578
|
-
}
|
|
4579
|
-
|
|
4580
|
-
// Merge decorators from both Python-style decorators and Java-style modifiers
|
|
4581
|
-
const allDecorators = def.decorators || null;
|
|
4582
|
-
|
|
4583
|
-
results.push({
|
|
4584
|
-
kind: def.type,
|
|
4585
|
-
name: symbolName,
|
|
4586
|
-
file: def.relativePath,
|
|
4587
|
-
line: def.startLine,
|
|
4588
|
-
params: def.params || null,
|
|
4589
|
-
returnType: def.returnType || null,
|
|
4590
|
-
decorators: allDecorators,
|
|
4591
|
-
className: def.className || null,
|
|
4592
|
-
exported: exported ? true : undefined,
|
|
4593
|
-
});
|
|
4594
|
-
}
|
|
4595
|
-
}
|
|
4596
|
-
}
|
|
4597
|
-
|
|
4598
|
-
// Sort by file, then line
|
|
4599
|
-
results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
4600
|
-
|
|
4601
|
-
// Apply top limit
|
|
4602
|
-
const total = results.length;
|
|
4603
|
-
const top = options.top;
|
|
4604
|
-
if (top && top > 0 && results.length > top) {
|
|
4605
|
-
results.length = top;
|
|
4606
|
-
}
|
|
4607
|
-
|
|
4608
|
-
return {
|
|
4609
|
-
results,
|
|
4610
|
-
meta: {
|
|
4611
|
-
mode: 'structural',
|
|
4612
|
-
query: Object.fromEntries(Object.entries({
|
|
4613
|
-
type: type || 'any', term, param, receiver, returns: returnType,
|
|
4614
|
-
decorator, exported: exported || undefined, unused: unused || undefined,
|
|
4615
|
-
}).filter(([, v]) => v !== undefined && v !== null)),
|
|
4616
|
-
totalMatched: total,
|
|
4617
|
-
shown: results.length,
|
|
4618
|
-
}
|
|
4619
|
-
};
|
|
4620
|
-
} finally { this._endOp(); }
|
|
4621
|
-
}
|
|
4622
|
-
|
|
4623
|
-
// ========================================================================
|
|
4624
|
-
// PROJECT INFO
|
|
4625
|
-
// ========================================================================
|
|
4626
|
-
|
|
4627
|
-
/**
|
|
4628
|
-
* Get project statistics
|
|
4629
|
-
*/
|
|
4630
|
-
getStats(options = {}) {
|
|
4631
|
-
// Count total symbols (not just unique names)
|
|
4632
|
-
let totalSymbols = 0;
|
|
4633
|
-
for (const [name, symbols] of this.symbols) {
|
|
4634
|
-
totalSymbols += symbols.length;
|
|
4635
|
-
}
|
|
4636
|
-
|
|
4637
|
-
const stats = {
|
|
4638
|
-
root: this.root,
|
|
4639
|
-
files: this.files.size,
|
|
4640
|
-
symbols: totalSymbols, // Total symbol count, not unique names
|
|
4641
|
-
buildTime: this.buildTime,
|
|
4642
|
-
byLanguage: {},
|
|
4643
|
-
byType: {},
|
|
4644
|
-
...(this.truncated && { truncated: this.truncated })
|
|
4645
|
-
};
|
|
4646
|
-
|
|
4647
|
-
for (const [filePath, fileEntry] of this.files) {
|
|
4648
|
-
const lang = fileEntry.language;
|
|
4649
|
-
if (!stats.byLanguage[lang]) {
|
|
4650
|
-
stats.byLanguage[lang] = { files: 0, lines: 0, symbols: 0 };
|
|
4651
|
-
}
|
|
4652
|
-
stats.byLanguage[lang].files++;
|
|
4653
|
-
stats.byLanguage[lang].lines += fileEntry.lines;
|
|
4654
|
-
stats.byLanguage[lang].symbols += fileEntry.symbols.length;
|
|
4655
|
-
}
|
|
4656
|
-
|
|
4657
|
-
for (const [name, symbols] of this.symbols) {
|
|
4658
|
-
for (const sym of symbols) {
|
|
4659
|
-
if (!Object.hasOwn(stats.byType, sym.type)) {
|
|
4660
|
-
stats.byType[sym.type] = 0;
|
|
4661
|
-
}
|
|
4662
|
-
stats.byType[sym.type]++;
|
|
4663
|
-
}
|
|
4664
|
-
}
|
|
4665
|
-
|
|
4666
|
-
// Per-function line counts for complexity audits
|
|
4667
|
-
if (options.functions) {
|
|
4668
|
-
const functions = [];
|
|
4669
|
-
for (const [name, symbols] of this.symbols) {
|
|
4670
|
-
for (const sym of symbols) {
|
|
4671
|
-
if (sym.type === 'function' || sym.type === 'method' || sym.type === 'static' ||
|
|
4672
|
-
sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract' ||
|
|
4673
|
-
sym.type === 'classmethod') {
|
|
4674
|
-
const lineCount = sym.endLine - sym.startLine + 1;
|
|
4675
|
-
const relativePath = sym.relativePath || (sym.file ? path.relative(this.root, sym.file) : '');
|
|
4676
|
-
functions.push({
|
|
4677
|
-
name: sym.className ? `${sym.className}.${sym.name}` : sym.name,
|
|
4678
|
-
file: relativePath,
|
|
4679
|
-
startLine: sym.startLine,
|
|
4680
|
-
lines: lineCount
|
|
4681
|
-
});
|
|
4682
|
-
}
|
|
4683
|
-
}
|
|
4684
|
-
}
|
|
4685
|
-
functions.sort((a, b) => b.lines - a.lines);
|
|
4686
|
-
stats.functions = functions;
|
|
4687
|
-
}
|
|
4688
|
-
|
|
4689
|
-
return stats;
|
|
4690
|
-
}
|
|
4691
|
-
|
|
4692
|
-
/**
|
|
4693
|
-
* Get TOC for all files
|
|
4694
|
-
*/
|
|
4695
|
-
getToc(options = {}) {
|
|
4696
|
-
const files = [];
|
|
4697
|
-
let totalFunctions = 0;
|
|
4698
|
-
let totalClasses = 0;
|
|
4699
|
-
let totalState = 0;
|
|
4700
|
-
let totalLines = 0;
|
|
4701
|
-
let totalDynamic = 0;
|
|
4702
|
-
let totalTests = 0;
|
|
4703
|
-
|
|
4704
|
-
// When file= is specified, scope to matching files only
|
|
4705
|
-
let fileFilter = null;
|
|
4706
|
-
if (options.file) {
|
|
4707
|
-
const resolved = this.findFile(options.file);
|
|
4708
|
-
if (resolved) {
|
|
4709
|
-
fileFilter = new Set([resolved]);
|
|
4710
|
-
} else {
|
|
4711
|
-
// Try substring match for partial paths
|
|
4712
|
-
const matching = [];
|
|
4713
|
-
for (const fp of this.files.keys()) {
|
|
4714
|
-
const rp = path.relative(this.root, fp);
|
|
4715
|
-
if (rp.includes(options.file) || fp.includes(options.file)) {
|
|
4716
|
-
matching.push(fp);
|
|
4717
|
-
}
|
|
4718
|
-
}
|
|
4719
|
-
if (matching.length > 0) {
|
|
4720
|
-
fileFilter = new Set(matching);
|
|
4721
|
-
} else {
|
|
4722
|
-
return {
|
|
4723
|
-
meta: { complete: true, skipped: 0, dynamicImports: 0, uncertain: 0 },
|
|
4724
|
-
totals: { files: 0, lines: 0, functions: 0, classes: 0, state: 0, testFiles: 0 },
|
|
4725
|
-
summary: { topFunctionFiles: [], topLineFiles: [], entryFiles: [] },
|
|
4726
|
-
files: [],
|
|
4727
|
-
hiddenFiles: 0,
|
|
4728
|
-
error: `File not found in project: ${options.file}`
|
|
4729
|
-
};
|
|
4730
|
-
}
|
|
4731
|
-
}
|
|
4732
|
-
}
|
|
4733
|
-
|
|
4734
|
-
for (const [filePath, fileEntry] of this.files) {
|
|
4735
|
-
if (fileFilter && !fileFilter.has(filePath)) continue;
|
|
4736
|
-
if (options.exclude && options.exclude.length > 0) {
|
|
4737
|
-
if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude })) continue;
|
|
4738
|
-
}
|
|
4739
|
-
if (options.in) {
|
|
4740
|
-
if (!this.matchesFilters(fileEntry.relativePath, { in: options.in })) continue;
|
|
4741
|
-
}
|
|
4742
|
-
let functions = fileEntry.symbols.filter(s =>
|
|
4743
|
-
s.type === 'function' || s.type === 'method' || s.type === 'static' ||
|
|
4744
|
-
s.type === 'constructor' || s.type === 'public' || s.type === 'abstract' ||
|
|
4745
|
-
s.type === 'classmethod'
|
|
4746
|
-
);
|
|
4747
|
-
const classes = fileEntry.symbols.filter(s =>
|
|
4748
|
-
['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl', 'record', 'namespace'].includes(s.type)
|
|
4749
|
-
);
|
|
4750
|
-
const state = fileEntry.symbols.filter(s => s.type === 'state');
|
|
4751
|
-
|
|
4752
|
-
if (options.topLevel) {
|
|
4753
|
-
functions = functions.filter(fn => !fn.isNested && (!fn.indent || fn.indent === 0));
|
|
4754
|
-
}
|
|
4755
|
-
|
|
4756
|
-
totalFunctions += functions.length;
|
|
4757
|
-
totalClasses += classes.length;
|
|
4758
|
-
totalState += state.length;
|
|
4759
|
-
totalLines += fileEntry.lines;
|
|
4760
|
-
totalDynamic += fileEntry.dynamicImports || 0;
|
|
4761
|
-
if (isTestFile(fileEntry.relativePath, fileEntry.language)) totalTests += 1;
|
|
4762
|
-
|
|
4763
|
-
const entry = {
|
|
4764
|
-
file: fileEntry.relativePath,
|
|
4765
|
-
language: fileEntry.language,
|
|
4766
|
-
lines: fileEntry.lines,
|
|
4767
|
-
functions: functions.length,
|
|
4768
|
-
classes: classes.length,
|
|
4769
|
-
state: state.length
|
|
4770
|
-
};
|
|
4771
|
-
|
|
4772
|
-
if (options.detailed) {
|
|
4773
|
-
entry.symbols = { functions, classes, state };
|
|
4774
|
-
}
|
|
4775
|
-
|
|
4776
|
-
files.push(entry);
|
|
4777
|
-
}
|
|
4778
|
-
|
|
4779
|
-
// Hints: top files by function count and lines
|
|
4780
|
-
const hintLimit = options.all ? Infinity : 3;
|
|
4781
|
-
const topFunctionFiles = [...files]
|
|
4782
|
-
.sort((a, b) => b.functions - a.functions || b.lines - a.lines)
|
|
4783
|
-
.filter(f => f.functions > 0)
|
|
4784
|
-
.slice(0, hintLimit)
|
|
4785
|
-
.map(f => ({ file: f.file, functions: f.functions }));
|
|
4786
|
-
|
|
4787
|
-
const topLineFiles = [...files]
|
|
4788
|
-
.sort((a, b) => b.lines - a.lines)
|
|
4789
|
-
.slice(0, hintLimit)
|
|
4790
|
-
.map(f => ({ file: f.file, lines: f.lines }));
|
|
4791
|
-
|
|
4792
|
-
// Entry point candidates
|
|
4793
|
-
const entryPattern = /(main|index|server|app)\.(js|jsx|ts|tsx|py|go|rs|java)$/i;
|
|
4794
|
-
const entryFiles = files
|
|
4795
|
-
.filter(f => entryPattern.test(f.file))
|
|
4796
|
-
.slice(0, options.all ? Infinity : 5)
|
|
4797
|
-
.map(f => f.file);
|
|
4798
|
-
|
|
4799
|
-
// Also detect entry points from package.json main/exports fields
|
|
4800
|
-
const pkgJsonPath = path.join(this.root, 'package.json');
|
|
4801
|
-
try {
|
|
4802
|
-
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
4803
|
-
const mainField = pkgJson.main || pkgJson.module;
|
|
4804
|
-
if (mainField) {
|
|
4805
|
-
const mainFile = path.relative(this.root, path.resolve(this.root, mainField));
|
|
4806
|
-
if (files.some(f => f.file === mainFile) && !entryFiles.includes(mainFile)) {
|
|
4807
|
-
entryFiles.unshift(mainFile);
|
|
4808
|
-
}
|
|
4809
|
-
}
|
|
4810
|
-
} catch {
|
|
4811
|
-
// No package.json or invalid JSON — skip
|
|
4812
|
-
}
|
|
4813
|
-
|
|
4814
|
-
// Apply top limit for detailed mode to avoid massive output
|
|
4815
|
-
const top = options.top > 0 ? options.top : (options.detailed && !options.all ? 50 : Infinity);
|
|
4816
|
-
let hiddenFiles = 0;
|
|
4817
|
-
let displayFiles = files;
|
|
4818
|
-
if (top < files.length) {
|
|
4819
|
-
hiddenFiles = files.length - top;
|
|
4820
|
-
displayFiles = files.slice(0, top);
|
|
4821
|
-
}
|
|
4822
|
-
|
|
4823
|
-
// Count files with no symbols (generated/empty files)
|
|
4824
|
-
const emptyFiles = files.filter(f => f.functions === 0 && f.classes === 0 && f.state === 0).length;
|
|
4825
|
-
|
|
4826
|
-
return {
|
|
4827
|
-
meta: {
|
|
4828
|
-
complete: totalDynamic === 0,
|
|
4829
|
-
skipped: 0,
|
|
4830
|
-
dynamicImports: totalDynamic,
|
|
4831
|
-
uncertain: 0,
|
|
4832
|
-
projectLanguage: this._getPredominantLanguage(),
|
|
4833
|
-
...(fileFilter && { filteredBy: options.file, matchedFiles: files.length }),
|
|
4834
|
-
...(options.in && { scopedTo: options.in }),
|
|
4835
|
-
...(emptyFiles > 0 && fileFilter && { emptyFiles })
|
|
4836
|
-
},
|
|
4837
|
-
totals: {
|
|
4838
|
-
files: files.length,
|
|
4839
|
-
lines: totalLines,
|
|
4840
|
-
functions: totalFunctions,
|
|
4841
|
-
classes: totalClasses,
|
|
4842
|
-
state: totalState,
|
|
4843
|
-
testFiles: totalTests
|
|
4844
|
-
},
|
|
4845
|
-
summary: {
|
|
4846
|
-
topFunctionFiles,
|
|
4847
|
-
topLineFiles,
|
|
4848
|
-
entryFiles
|
|
4849
|
-
},
|
|
4850
|
-
files: displayFiles,
|
|
4851
|
-
hiddenFiles
|
|
4852
|
-
};
|
|
4853
|
-
}
|
|
4854
|
-
|
|
4855
|
-
// ========================================================================
|
|
4856
|
-
// CACHE METHODS
|
|
4857
|
-
// ========================================================================
|
|
4858
|
-
|
|
4859
|
-
/** Save index to cache file */
|
|
4860
|
-
saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
|
|
4861
|
-
|
|
4862
|
-
/** Load index from cache file */
|
|
4863
|
-
loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
|
|
4864
|
-
|
|
4865
|
-
/** Load callsCache from separate file on demand (called by findCallers/findCallees) */
|
|
4866
|
-
loadCallsCache() { return indexCache.loadCallsCache(this); }
|
|
4867
|
-
|
|
4868
|
-
/** Check if cache is stale (any files changed or new files added) */
|
|
4869
|
-
isCacheStale() { return indexCache.isCacheStale(this); }
|
|
4870
|
-
|
|
4871
|
-
/**
|
|
4872
|
-
* Find the best usage example of a function.
|
|
4873
|
-
* Scores call sites using AST analysis (await, destructuring, typed assignment, etc.)
|
|
4874
|
-
* @param {string} name - Symbol name
|
|
4875
|
-
* @returns {{ best: object, totalCalls: number } | null}
|
|
4876
|
-
*/
|
|
4877
|
-
example(name, options = {}) {
|
|
4878
|
-
this._beginOp();
|
|
4879
|
-
try {
|
|
4880
|
-
const usages = this.usages(name, {
|
|
4881
|
-
codeOnly: true,
|
|
4882
|
-
className: options.className,
|
|
4883
|
-
exclude: ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'],
|
|
4884
|
-
context: 5
|
|
4885
|
-
});
|
|
4886
|
-
|
|
4887
|
-
const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
|
|
4888
|
-
if (calls.length === 0) return null;
|
|
4889
|
-
|
|
4890
|
-
const scored = calls.map(call => {
|
|
4891
|
-
let score = 0;
|
|
4892
|
-
const reasons = [];
|
|
4893
|
-
const line = call.content.trim();
|
|
4894
|
-
|
|
4895
|
-
const astInfo = this._analyzeCallSiteAST(call.file, call.line, name);
|
|
4896
|
-
|
|
4897
|
-
if (astInfo.isTypedAssignment) { score += 15; reasons.push('typed assignment'); }
|
|
4898
|
-
if (astInfo.isInReturn) { score += 10; reasons.push('in return'); }
|
|
4899
|
-
if (astInfo.isAwait) { score += 10; reasons.push('async usage'); }
|
|
4900
|
-
if (astInfo.isDestructured) { score += 8; reasons.push('destructured'); }
|
|
4901
|
-
if (astInfo.isStandalone) { score += 5; reasons.push('standalone'); }
|
|
4902
|
-
if (astInfo.hasComment) { score += 3; reasons.push('documented'); }
|
|
4903
|
-
if (astInfo.isInCatch) { score -= 5; reasons.push('in catch block'); }
|
|
4904
|
-
if (astInfo.isInConditional) { score -= 3; reasons.push('in conditional'); }
|
|
4905
|
-
|
|
4906
|
-
if (score === 0) {
|
|
4907
|
-
if (/^(const|let|var|return)\s/.test(line) || /^\w+\s*=/.test(line)) {
|
|
4908
|
-
score += 10; reasons.push('return value used');
|
|
4909
|
-
}
|
|
4910
|
-
if (line.startsWith(name + '(') || /^(const|let|var)\s+\w+\s*=\s*\w*$/.test(line.split(name)[0])) {
|
|
4911
|
-
score += 5; reasons.push('clear usage');
|
|
4912
|
-
}
|
|
4913
|
-
}
|
|
4914
|
-
|
|
4915
|
-
if (call.before && call.before.length > 0) score += 3;
|
|
4916
|
-
if (call.after && call.after.length > 0) score += 3;
|
|
4917
|
-
if (call.before?.length > 0 && call.after?.length > 0) reasons.push('has context');
|
|
4918
|
-
|
|
4919
|
-
const beforeCall = line.split(name + '(')[0];
|
|
4920
|
-
if (!beforeCall.includes('(') || /^\s*(const|let|var|return)?\s*\w+\s*=\s*$/.test(beforeCall)) {
|
|
4921
|
-
score += 2;
|
|
4922
|
-
}
|
|
4923
|
-
if (call.line < 100) score += 1;
|
|
4924
|
-
|
|
4925
|
-
return { ...call, score, reasons };
|
|
4926
|
-
});
|
|
4927
|
-
|
|
4928
|
-
scored.sort((a, b) => b.score - a.score);
|
|
4929
|
-
return { best: scored[0], totalCalls: calls.length };
|
|
4930
|
-
} finally { this._endOp(); }
|
|
4931
|
-
}
|
|
2045
|
+
example(name, options) { return searchModule.example(this, name, options); }
|
|
4932
2046
|
|
|
4933
2047
|
/** Analyze a call site using AST for example scoring */
|
|
4934
2048
|
_analyzeCallSiteAST(filePath, lineNum, funcName) { return verifyModule.analyzeCallSiteAST(this, filePath, lineNum, funcName); }
|
|
4935
2049
|
|
|
4936
|
-
/**
|
|
4937
|
-
|
|
4938
|
-
*
|
|
4939
|
-
* @param {object} options - { base, staged, file }
|
|
4940
|
-
* @returns {object} - { base, functions, moduleLevelChanges, newFunctions, deletedFunctions, summary }
|
|
4941
|
-
*/
|
|
4942
|
-
diffImpact(options = {}) {
|
|
4943
|
-
this._beginOp();
|
|
4944
|
-
try {
|
|
4945
|
-
const { base = 'HEAD', staged = false, file } = options;
|
|
4946
|
-
|
|
4947
|
-
// Validate base ref format to prevent argument injection
|
|
4948
|
-
if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) {
|
|
4949
|
-
throw new Error(`Invalid git ref format: ${base}`);
|
|
4950
|
-
}
|
|
4951
|
-
|
|
4952
|
-
// Verify git repo
|
|
4953
|
-
let gitRoot;
|
|
4954
|
-
try {
|
|
4955
|
-
gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: this.root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
4956
|
-
} catch (e) {
|
|
4957
|
-
throw new Error('Not a git repository. diff-impact requires git.');
|
|
4958
|
-
}
|
|
4959
|
-
|
|
4960
|
-
// Build git diff command (use execFileSync to avoid shell expansion)
|
|
4961
|
-
const diffArgs = ['diff', '--unified=0'];
|
|
4962
|
-
if (staged) {
|
|
4963
|
-
diffArgs.push('--staged');
|
|
4964
|
-
} else {
|
|
4965
|
-
diffArgs.push(base);
|
|
4966
|
-
}
|
|
4967
|
-
if (file) {
|
|
4968
|
-
diffArgs.push('--', file);
|
|
4969
|
-
}
|
|
4970
|
-
|
|
4971
|
-
let diffText;
|
|
4972
|
-
try {
|
|
4973
|
-
diffText = execFileSync('git', diffArgs, { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
4974
|
-
} catch (e) {
|
|
4975
|
-
// git diff exits non-zero when there are diff errors, but also for invalid refs
|
|
4976
|
-
if (e.stdout) {
|
|
4977
|
-
diffText = e.stdout;
|
|
4978
|
-
} else {
|
|
4979
|
-
throw new Error(`git diff failed: ${e.message}`);
|
|
4980
|
-
}
|
|
4981
|
-
}
|
|
4982
|
-
|
|
4983
|
-
if (!diffText || !diffText.trim()) {
|
|
4984
|
-
return {
|
|
4985
|
-
base: staged ? '(staged)' : base,
|
|
4986
|
-
functions: [],
|
|
4987
|
-
moduleLevelChanges: [],
|
|
4988
|
-
newFunctions: [],
|
|
4989
|
-
deletedFunctions: [],
|
|
4990
|
-
summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, affectedFiles: 0 }
|
|
4991
|
-
};
|
|
4992
|
-
}
|
|
4993
|
-
|
|
4994
|
-
// Diff paths are git-root-relative. Resolve to this.root for file lookup.
|
|
4995
|
-
// Normalize both through realpath to handle macOS /var → /private/var symlinks.
|
|
4996
|
-
let realGitRoot, realProjectRoot;
|
|
4997
|
-
try { realGitRoot = fs.realpathSync(gitRoot); } catch (_) { realGitRoot = gitRoot; }
|
|
4998
|
-
try { realProjectRoot = fs.realpathSync(this.root); } catch (_) { realProjectRoot = this.root; }
|
|
4999
|
-
const projectPrefix = realGitRoot === realProjectRoot
|
|
5000
|
-
? ''
|
|
5001
|
-
: path.relative(realGitRoot, realProjectRoot);
|
|
5002
|
-
|
|
5003
|
-
const rawChanges = parseDiff(diffText, gitRoot);
|
|
5004
|
-
// Filter to files under this.root and remap paths.
|
|
5005
|
-
// Preserve gitRelativePath (repo-relative) for git show commands.
|
|
5006
|
-
const changes = [];
|
|
5007
|
-
for (const c of rawChanges) {
|
|
5008
|
-
if (projectPrefix && !c.relativePath.startsWith(projectPrefix + '/')) continue;
|
|
5009
|
-
const localRel = projectPrefix ? c.relativePath.slice(projectPrefix.length + 1) : c.relativePath;
|
|
5010
|
-
changes.push({ ...c, gitRelativePath: c.relativePath, filePath: path.join(this.root, localRel), relativePath: localRel });
|
|
5011
|
-
}
|
|
5012
|
-
|
|
5013
|
-
const functions = [];
|
|
5014
|
-
const moduleLevelChanges = [];
|
|
5015
|
-
const newFunctions = [];
|
|
5016
|
-
const deletedFunctions = [];
|
|
5017
|
-
const callerFileSet = new Set();
|
|
5018
|
-
let totalCallSites = 0;
|
|
5019
|
-
|
|
5020
|
-
for (const change of changes) {
|
|
5021
|
-
const lang = detectLanguage(change.filePath);
|
|
5022
|
-
if (!lang) continue;
|
|
5023
|
-
|
|
5024
|
-
const fileEntry = this.files.get(change.filePath);
|
|
5025
|
-
|
|
5026
|
-
// Handle deleted files: entire file was removed, all functions are deleted
|
|
5027
|
-
if (!fileEntry) {
|
|
5028
|
-
if (change.isDeleted && change.deletedLines.length > 0) {
|
|
5029
|
-
const ref = staged ? 'HEAD' : base;
|
|
5030
|
-
try {
|
|
5031
|
-
const oldContent = execFileSync(
|
|
5032
|
-
'git', ['show', `${ref}:${change.gitRelativePath}`],
|
|
5033
|
-
{ cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
5034
|
-
);
|
|
5035
|
-
const oldParsed = parse(oldContent, lang);
|
|
5036
|
-
for (const oldFn of extractCallableSymbols(oldParsed)) {
|
|
5037
|
-
deletedFunctions.push({
|
|
5038
|
-
name: oldFn.name,
|
|
5039
|
-
filePath: change.filePath,
|
|
5040
|
-
relativePath: change.relativePath,
|
|
5041
|
-
startLine: oldFn.startLine
|
|
5042
|
-
});
|
|
5043
|
-
}
|
|
5044
|
-
} catch (e) {
|
|
5045
|
-
// git show failed — skip
|
|
5046
|
-
}
|
|
5047
|
-
}
|
|
5048
|
-
continue;
|
|
5049
|
-
}
|
|
5050
|
-
|
|
5051
|
-
// Track which functions are affected by added/modified lines
|
|
5052
|
-
const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
|
|
5053
|
-
|
|
5054
|
-
for (const line of change.addedLines) {
|
|
5055
|
-
const symbol = this.findEnclosingFunction(change.filePath, line, true);
|
|
5056
|
-
if (symbol) {
|
|
5057
|
-
const key = `${symbol.name}:${symbol.startLine}`;
|
|
5058
|
-
if (!affectedSymbols.has(key)) {
|
|
5059
|
-
affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
|
|
5060
|
-
}
|
|
5061
|
-
affectedSymbols.get(key).addedLines.push(line);
|
|
5062
|
-
} else {
|
|
5063
|
-
// Module-level change
|
|
5064
|
-
const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
|
|
5065
|
-
if (existing) {
|
|
5066
|
-
existing.addedLines.push(line);
|
|
5067
|
-
} else {
|
|
5068
|
-
moduleLevelChanges.push({
|
|
5069
|
-
filePath: change.filePath,
|
|
5070
|
-
relativePath: change.relativePath,
|
|
5071
|
-
addedLines: [line],
|
|
5072
|
-
deletedLines: []
|
|
5073
|
-
});
|
|
5074
|
-
}
|
|
5075
|
-
}
|
|
5076
|
-
}
|
|
5077
|
-
|
|
5078
|
-
for (const line of change.deletedLines) {
|
|
5079
|
-
// For deleted lines, we can't use findEnclosingFunction on the current file
|
|
5080
|
-
// since those lines no longer exist. Track as module-level unless they map
|
|
5081
|
-
// to a function that still exists (the function was modified, not deleted).
|
|
5082
|
-
// We approximate: if a deleted line is within the range of a known symbol, it's a modification.
|
|
5083
|
-
let matched = false;
|
|
5084
|
-
for (const symbol of fileEntry.symbols) {
|
|
5085
|
-
if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
|
|
5086
|
-
// Use a generous range — deleted lines near a function likely belong to it
|
|
5087
|
-
if (line >= symbol.startLine - 2 && line <= symbol.endLine + 2) {
|
|
5088
|
-
const key = `${symbol.name}:${symbol.startLine}`;
|
|
5089
|
-
if (!affectedSymbols.has(key)) {
|
|
5090
|
-
affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
|
|
5091
|
-
}
|
|
5092
|
-
affectedSymbols.get(key).deletedLines.push(line);
|
|
5093
|
-
matched = true;
|
|
5094
|
-
break;
|
|
5095
|
-
}
|
|
5096
|
-
}
|
|
5097
|
-
if (!matched) {
|
|
5098
|
-
const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
|
|
5099
|
-
if (existing) {
|
|
5100
|
-
existing.deletedLines.push(line);
|
|
5101
|
-
} else {
|
|
5102
|
-
moduleLevelChanges.push({
|
|
5103
|
-
filePath: change.filePath,
|
|
5104
|
-
relativePath: change.relativePath,
|
|
5105
|
-
addedLines: [],
|
|
5106
|
-
deletedLines: [line]
|
|
5107
|
-
});
|
|
5108
|
-
}
|
|
5109
|
-
}
|
|
5110
|
-
}
|
|
5111
|
-
|
|
5112
|
-
// Detect new functions: all added lines are within a single function range
|
|
5113
|
-
// and the function didn't exist before (approximation: all lines in the function are added)
|
|
5114
|
-
for (const [key, data] of affectedSymbols) {
|
|
5115
|
-
const { symbol, addedLines } = data;
|
|
5116
|
-
const fnLineCount = symbol.endLine - symbol.startLine + 1;
|
|
5117
|
-
if (addedLines.length >= fnLineCount * 0.8 && data.deletedLines.length === 0) {
|
|
5118
|
-
newFunctions.push({
|
|
5119
|
-
name: symbol.name,
|
|
5120
|
-
filePath: change.filePath,
|
|
5121
|
-
relativePath: change.relativePath,
|
|
5122
|
-
startLine: symbol.startLine,
|
|
5123
|
-
endLine: symbol.endLine,
|
|
5124
|
-
signature: this.formatSignature(symbol)
|
|
5125
|
-
});
|
|
5126
|
-
affectedSymbols.delete(key);
|
|
5127
|
-
}
|
|
5128
|
-
}
|
|
5129
|
-
|
|
5130
|
-
// Detect deleted functions: compare old file symbols with current by identity.
|
|
5131
|
-
// Uses name+className counts to handle overloads (e.g. Java method overloading).
|
|
5132
|
-
if (change.deletedLines.length > 0) {
|
|
5133
|
-
const ref = staged ? 'HEAD' : base;
|
|
5134
|
-
try {
|
|
5135
|
-
const oldContent = execFileSync(
|
|
5136
|
-
'git', ['show', `${ref}:${change.gitRelativePath}`],
|
|
5137
|
-
{ cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
5138
|
-
);
|
|
5139
|
-
const fileLang = detectLanguage(change.filePath);
|
|
5140
|
-
if (fileLang) {
|
|
5141
|
-
const oldParsed = parse(oldContent, fileLang);
|
|
5142
|
-
// Count current symbols by identity (name + className)
|
|
5143
|
-
const currentCounts = new Map();
|
|
5144
|
-
for (const s of fileEntry.symbols) {
|
|
5145
|
-
if (NON_CALLABLE_TYPES.has(s.type)) continue;
|
|
5146
|
-
const key = `${s.name}\0${s.className || ''}`;
|
|
5147
|
-
currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
|
|
5148
|
-
}
|
|
5149
|
-
// Count old symbols by identity and detect deletions
|
|
5150
|
-
const oldCounts = new Map();
|
|
5151
|
-
const oldSymbols = extractCallableSymbols(oldParsed);
|
|
5152
|
-
for (const oldFn of oldSymbols) {
|
|
5153
|
-
const key = `${oldFn.name}\0${oldFn.className || ''}`;
|
|
5154
|
-
oldCounts.set(key, (oldCounts.get(key) || 0) + 1);
|
|
5155
|
-
}
|
|
5156
|
-
// For each identity, if old count > current count, the difference are deletions
|
|
5157
|
-
for (const [key, oldCount] of oldCounts) {
|
|
5158
|
-
const curCount = currentCounts.get(key) || 0;
|
|
5159
|
-
if (oldCount > curCount) {
|
|
5160
|
-
// Find the specific old symbols with this identity that were deleted
|
|
5161
|
-
const matching = oldSymbols.filter(s => `${s.name}\0${s.className || ''}` === key);
|
|
5162
|
-
// Report the extra ones (by startLine descending — later ones more likely deleted)
|
|
5163
|
-
const toReport = matching.slice(curCount);
|
|
5164
|
-
for (const oldFn of toReport) {
|
|
5165
|
-
deletedFunctions.push({
|
|
5166
|
-
name: oldFn.name,
|
|
5167
|
-
filePath: change.filePath,
|
|
5168
|
-
relativePath: change.relativePath,
|
|
5169
|
-
startLine: oldFn.startLine
|
|
5170
|
-
});
|
|
5171
|
-
}
|
|
5172
|
-
}
|
|
5173
|
-
}
|
|
5174
|
-
}
|
|
5175
|
-
} catch (e) {
|
|
5176
|
-
// File didn't exist in base, or git error — skip
|
|
5177
|
-
}
|
|
5178
|
-
}
|
|
5179
|
-
|
|
5180
|
-
// For each affected function, find callers
|
|
5181
|
-
for (const [, data] of affectedSymbols) {
|
|
5182
|
-
const { symbol, addedLines: aLines, deletedLines: dLines } = data;
|
|
5183
|
-
|
|
5184
|
-
// Get the specific definitions matching this symbol
|
|
5185
|
-
const allDefs = this.symbols.get(symbol.name) || [];
|
|
5186
|
-
const targetDefs = allDefs.filter(d => d.file === change.filePath && d.startLine === symbol.startLine);
|
|
5187
|
-
|
|
5188
|
-
let callers = this.findCallers(symbol.name, {
|
|
5189
|
-
targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
|
|
5190
|
-
includeMethods: true,
|
|
5191
|
-
includeUncertain: false,
|
|
5192
|
-
});
|
|
5193
|
-
|
|
5194
|
-
// For Go/Java/Rust methods with a className, filter callers whose
|
|
5195
|
-
// receiver clearly belongs to a different type (same logic as impact()).
|
|
5196
|
-
const targetDef = targetDefs[0] || symbol;
|
|
5197
|
-
if (targetDef.className && (lang === 'go' || lang === 'java' || lang === 'rust')) {
|
|
5198
|
-
const targetClassName = targetDef.className;
|
|
5199
|
-
// Pre-compute how many types share this method name
|
|
5200
|
-
const methodDefs = this.symbols.get(symbol.name);
|
|
5201
|
-
const classNames = new Set();
|
|
5202
|
-
if (methodDefs) {
|
|
5203
|
-
for (const d of methodDefs) {
|
|
5204
|
-
if (d.className) classNames.add(d.className);
|
|
5205
|
-
else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
|
|
5206
|
-
}
|
|
5207
|
-
}
|
|
5208
|
-
const isWidelyShared = classNames.size > 3;
|
|
5209
|
-
callers = callers.filter(c => {
|
|
5210
|
-
if (!c.isMethod) return true;
|
|
5211
|
-
const r = c.receiver;
|
|
5212
|
-
if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
|
|
5213
|
-
// No receiver (chained/complex expression): only include if method is
|
|
5214
|
-
// unique or rare across types — otherwise too many false positives
|
|
5215
|
-
if (!r) {
|
|
5216
|
-
return classNames.size <= 1;
|
|
5217
|
-
}
|
|
5218
|
-
// Use receiverType from findCallers when available
|
|
5219
|
-
if (c.receiverType) {
|
|
5220
|
-
return c.receiverType === targetClassName ||
|
|
5221
|
-
c.receiverType === targetDef.receiver?.replace(/^\*/, '');
|
|
5222
|
-
}
|
|
5223
|
-
// Unique method heuristic: if the method exists on exactly one class/type, include
|
|
5224
|
-
if (classNames.size === 1 && classNames.has(targetClassName)) return true;
|
|
5225
|
-
// For widely shared method names (Get, Set, Run, etc.), require same-package
|
|
5226
|
-
// evidence when receiver type is unknown
|
|
5227
|
-
if (isWidelyShared) {
|
|
5228
|
-
const callerFile = c.file || '';
|
|
5229
|
-
const targetDir = path.dirname(change.filePath);
|
|
5230
|
-
return path.dirname(callerFile) === targetDir;
|
|
5231
|
-
}
|
|
5232
|
-
// Unknown receiver + multiple classes with this method → filter out
|
|
5233
|
-
return false;
|
|
5234
|
-
});
|
|
5235
|
-
}
|
|
5236
|
-
|
|
5237
|
-
for (const c of callers) {
|
|
5238
|
-
callerFileSet.add(c.file);
|
|
5239
|
-
}
|
|
5240
|
-
totalCallSites += callers.length;
|
|
5241
|
-
|
|
5242
|
-
functions.push({
|
|
5243
|
-
name: symbol.name,
|
|
5244
|
-
filePath: change.filePath,
|
|
5245
|
-
relativePath: change.relativePath,
|
|
5246
|
-
startLine: symbol.startLine,
|
|
5247
|
-
endLine: symbol.endLine,
|
|
5248
|
-
signature: this.formatSignature(symbol),
|
|
5249
|
-
addedLines: aLines,
|
|
5250
|
-
deletedLines: dLines,
|
|
5251
|
-
callers: callers.map(c => ({
|
|
5252
|
-
file: c.file,
|
|
5253
|
-
relativePath: c.relativePath,
|
|
5254
|
-
line: c.line,
|
|
5255
|
-
callerName: c.callerName,
|
|
5256
|
-
content: c.content.trim()
|
|
5257
|
-
}))
|
|
5258
|
-
});
|
|
5259
|
-
}
|
|
5260
|
-
}
|
|
5261
|
-
|
|
5262
|
-
return {
|
|
5263
|
-
base: staged ? '(staged)' : base,
|
|
5264
|
-
functions,
|
|
5265
|
-
moduleLevelChanges,
|
|
5266
|
-
newFunctions,
|
|
5267
|
-
deletedFunctions,
|
|
5268
|
-
summary: {
|
|
5269
|
-
modifiedFunctions: functions.length,
|
|
5270
|
-
deletedFunctions: deletedFunctions.length,
|
|
5271
|
-
newFunctions: newFunctions.length,
|
|
5272
|
-
totalCallSites,
|
|
5273
|
-
affectedFiles: callerFileSet.size
|
|
5274
|
-
}
|
|
5275
|
-
};
|
|
5276
|
-
} finally { this._endOp(); }
|
|
5277
|
-
}
|
|
5278
|
-
}
|
|
5279
|
-
|
|
5280
|
-
/**
|
|
5281
|
-
* Extract all callable symbols (functions + class methods) from a parse result,
|
|
5282
|
-
* matching how indexFile builds the symbol list. Methods get className added.
|
|
5283
|
-
* @param {object} parsed - Result from parse()
|
|
5284
|
-
* @returns {Array<{name, className, startLine}>}
|
|
5285
|
-
*/
|
|
5286
|
-
function extractCallableSymbols(parsed) {
|
|
5287
|
-
const symbols = [];
|
|
5288
|
-
for (const fn of parsed.functions) {
|
|
5289
|
-
symbols.push({ name: fn.name, className: fn.className || '', startLine: fn.startLine });
|
|
5290
|
-
}
|
|
5291
|
-
for (const cls of parsed.classes) {
|
|
5292
|
-
if (cls.members) {
|
|
5293
|
-
for (const m of cls.members) {
|
|
5294
|
-
symbols.push({ name: m.name, className: cls.name, startLine: m.startLine });
|
|
5295
|
-
}
|
|
5296
|
-
}
|
|
5297
|
-
}
|
|
5298
|
-
return symbols;
|
|
5299
|
-
}
|
|
5300
|
-
|
|
5301
|
-
/**
|
|
5302
|
-
* Unquote a git diff path: unescape C-style backslash sequences and strip tab metadata.
|
|
5303
|
-
* Git quotes paths containing special chars as "a/path\"with\"quotes".
|
|
5304
|
-
* @param {string} raw - Raw path string (may contain backslash escapes)
|
|
5305
|
-
* @returns {string} Unquoted path
|
|
5306
|
-
*/
|
|
5307
|
-
function unquoteDiffPath(raw) {
|
|
5308
|
-
const ESCAPES = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\t': '\t' };
|
|
5309
|
-
return raw
|
|
5310
|
-
.split('\t')[0]
|
|
5311
|
-
.replace(/\\[\\"nt]/g, m => ESCAPES[m]);
|
|
2050
|
+
/** Diff-based impact analysis: find which functions changed and who calls them */
|
|
2051
|
+
diffImpact(options) { return analysisModule.diffImpact(this, options); }
|
|
5312
2052
|
}
|
|
5313
2053
|
|
|
5314
|
-
|
|
5315
|
-
* Parse unified diff output into structured change data
|
|
5316
|
-
* @param {string} diffText - Output from `git diff --unified=0`
|
|
5317
|
-
* @param {string} root - Project root directory
|
|
5318
|
-
* @returns {Array<{ filePath, relativePath, addedLines, deletedLines }>}
|
|
5319
|
-
*/
|
|
5320
|
-
function parseDiff(diffText, root) {
|
|
5321
|
-
const changes = [];
|
|
5322
|
-
let currentFile = null;
|
|
5323
|
-
let pendingOldPath = null; // Track --- a/ path for deleted files
|
|
5324
|
-
|
|
5325
|
-
for (const line of diffText.split('\n')) {
|
|
5326
|
-
// Track old file path from --- header for deleted-file detection
|
|
5327
|
-
// Handles both unquoted (--- a/path) and quoted (--- "a/path") formats
|
|
5328
|
-
const oldMatch = line.match(/^--- (?:"a\/((?:[^"\\]|\\.)*)"|a\/(.+?))\s*$/);
|
|
5329
|
-
if (oldMatch) {
|
|
5330
|
-
const raw = oldMatch[1] !== undefined ? oldMatch[1] : oldMatch[2];
|
|
5331
|
-
pendingOldPath = unquoteDiffPath(raw);
|
|
5332
|
-
continue;
|
|
5333
|
-
}
|
|
5334
|
-
|
|
5335
|
-
// Match file header: +++ b/path or +++ "b/path" or +++ /dev/null
|
|
5336
|
-
if (line.startsWith('+++ ')) {
|
|
5337
|
-
let relativePath;
|
|
5338
|
-
const isDevNull = line.startsWith('+++ /dev/null');
|
|
5339
|
-
if (isDevNull) {
|
|
5340
|
-
// File was deleted — use the --- a/ path
|
|
5341
|
-
if (!pendingOldPath) continue;
|
|
5342
|
-
relativePath = pendingOldPath;
|
|
5343
|
-
} else {
|
|
5344
|
-
const newMatch = line.match(/^\+\+\+ (?:"b\/((?:[^"\\]|\\.)*)"|b\/(.+?))\s*$/);
|
|
5345
|
-
if (!newMatch) continue;
|
|
5346
|
-
const raw = newMatch[1] !== undefined ? newMatch[1] : newMatch[2];
|
|
5347
|
-
relativePath = unquoteDiffPath(raw);
|
|
5348
|
-
}
|
|
5349
|
-
pendingOldPath = null;
|
|
5350
|
-
currentFile = {
|
|
5351
|
-
filePath: path.join(root, relativePath),
|
|
5352
|
-
relativePath,
|
|
5353
|
-
addedLines: [],
|
|
5354
|
-
deletedLines: [],
|
|
5355
|
-
...(isDevNull && { isDeleted: true })
|
|
5356
|
-
};
|
|
5357
|
-
changes.push(currentFile);
|
|
5358
|
-
continue;
|
|
5359
|
-
}
|
|
5360
|
-
|
|
5361
|
-
// Match hunk header: @@ -old,count +new,count @@
|
|
5362
|
-
if (line.startsWith('@@') && currentFile) {
|
|
5363
|
-
const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
5364
|
-
if (match) {
|
|
5365
|
-
const oldStart = parseInt(match[1], 10);
|
|
5366
|
-
const oldCount = parseInt(match[2] || '1', 10);
|
|
5367
|
-
const newStart = parseInt(match[3], 10);
|
|
5368
|
-
const newCount = parseInt(match[4] || '1', 10);
|
|
5369
|
-
|
|
5370
|
-
// Deleted lines (from old file)
|
|
5371
|
-
if (oldCount > 0) {
|
|
5372
|
-
for (let i = 0; i < oldCount; i++) {
|
|
5373
|
-
currentFile.deletedLines.push(oldStart + i);
|
|
5374
|
-
}
|
|
5375
|
-
}
|
|
5376
|
-
|
|
5377
|
-
// Added lines (in new file)
|
|
5378
|
-
if (newCount > 0) {
|
|
5379
|
-
for (let i = 0; i < newCount; i++) {
|
|
5380
|
-
currentFile.addedLines.push(newStart + i);
|
|
5381
|
-
}
|
|
5382
|
-
}
|
|
5383
|
-
}
|
|
5384
|
-
}
|
|
5385
|
-
}
|
|
5386
|
-
|
|
5387
|
-
return changes;
|
|
5388
|
-
}
|
|
2054
|
+
const { parseDiff } = require('./analysis');
|
|
5389
2055
|
|
|
5390
2056
|
module.exports = { ProjectIndex, parseDiff };
|