ucn 3.7.47 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +32 -4
- package/README.md +16 -13
- package/cli/index.js +99 -10
- package/core/callers.js +36 -2
- package/core/deadcode.js +103 -3
- package/core/execute.js +97 -0
- package/core/output.js +397 -0
- package/core/project.js +924 -52
- package/core/registry.js +17 -7
- package/languages/go.js +7 -1
- package/languages/java.js +36 -2
- package/languages/javascript.js +199 -8
- package/languages/python.js +8 -2
- package/languages/rust.js +75 -4
- package/mcp/server.js +48 -3
- package/package.json +1 -1
package/core/project.js
CHANGED
|
@@ -24,6 +24,29 @@ const callersModule = require('./callers');
|
|
|
24
24
|
// Lazy-initialized per-language keyword sets (populated on first isKeyword call)
|
|
25
25
|
let LANGUAGE_KEYWORDS = null;
|
|
26
26
|
|
|
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
|
+
|
|
27
50
|
/**
|
|
28
51
|
* ProjectIndex - Manages symbol table for a project
|
|
29
52
|
*/
|
|
@@ -47,6 +70,7 @@ class ProjectIndex {
|
|
|
47
70
|
this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
|
|
48
71
|
this._opContentCache = null; // per-operation file content cache (Map<filePath, string>)
|
|
49
72
|
this._opUsagesCache = null; // per-operation findUsagesInCode cache (Map<"file:name", usages[]>)
|
|
73
|
+
this.calleeIndex = null; // name -> Set<filePath> — inverted call index (built lazily)
|
|
50
74
|
}
|
|
51
75
|
|
|
52
76
|
/**
|
|
@@ -407,10 +431,63 @@ class ProjectIndex {
|
|
|
407
431
|
// Invalidate cached call data for this file
|
|
408
432
|
this.callsCache.delete(filePath);
|
|
409
433
|
|
|
434
|
+
// Invalidate callee index (will be rebuilt lazily)
|
|
435
|
+
this.calleeIndex = null;
|
|
436
|
+
|
|
410
437
|
// Invalidate attribute type cache for this file
|
|
411
438
|
if (this._attrTypeCache) this._attrTypeCache.delete(filePath);
|
|
412
439
|
}
|
|
413
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Build inverted call index: callee name -> Set<filePath>.
|
|
443
|
+
* Built lazily on first findCallers call, from the calls cache.
|
|
444
|
+
* Enables O(relevant files) lookup instead of O(all files) scan.
|
|
445
|
+
*/
|
|
446
|
+
buildCalleeIndex() {
|
|
447
|
+
const { getCachedCalls } = require('./callers');
|
|
448
|
+
this.calleeIndex = new Map();
|
|
449
|
+
|
|
450
|
+
for (const [filePath] of this.files) {
|
|
451
|
+
const calls = getCachedCalls(this, filePath);
|
|
452
|
+
if (!calls) continue;
|
|
453
|
+
for (const call of calls) {
|
|
454
|
+
const name = call.name;
|
|
455
|
+
if (!this.calleeIndex.has(name)) {
|
|
456
|
+
this.calleeIndex.set(name, new Set());
|
|
457
|
+
}
|
|
458
|
+
this.calleeIndex.get(name).add(filePath);
|
|
459
|
+
// Also index resolvedName and resolvedNames for alias resolution
|
|
460
|
+
if (call.resolvedName && call.resolvedName !== name) {
|
|
461
|
+
if (!this.calleeIndex.has(call.resolvedName)) {
|
|
462
|
+
this.calleeIndex.set(call.resolvedName, new Set());
|
|
463
|
+
}
|
|
464
|
+
this.calleeIndex.get(call.resolvedName).add(filePath);
|
|
465
|
+
}
|
|
466
|
+
if (call.resolvedNames) {
|
|
467
|
+
for (const rn of call.resolvedNames) {
|
|
468
|
+
if (rn !== name) {
|
|
469
|
+
if (!this.calleeIndex.has(rn)) {
|
|
470
|
+
this.calleeIndex.set(rn, new Set());
|
|
471
|
+
}
|
|
472
|
+
this.calleeIndex.get(rn).add(filePath);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Get the set of files that contain calls to a given name.
|
|
482
|
+
* Returns null if callee index is not available (falls back to full scan).
|
|
483
|
+
*/
|
|
484
|
+
getCalleeFiles(name) {
|
|
485
|
+
if (!this.calleeIndex) {
|
|
486
|
+
this.buildCalleeIndex();
|
|
487
|
+
}
|
|
488
|
+
return this.calleeIndex.get(name) || null;
|
|
489
|
+
}
|
|
490
|
+
|
|
414
491
|
/**
|
|
415
492
|
* Resolve a Java package import to a project file.
|
|
416
493
|
* Handles regular imports, static imports (strips member name), and wildcards (strips .*).
|
|
@@ -553,7 +630,7 @@ class ProjectIndex {
|
|
|
553
630
|
const classNames = new Set();
|
|
554
631
|
for (const [, fileEntry] of this.files) {
|
|
555
632
|
for (const symbol of fileEntry.symbols) {
|
|
556
|
-
if (['class', 'interface', 'struct', 'trait'].includes(symbol.type)) {
|
|
633
|
+
if (['class', 'interface', 'struct', 'trait', 'record'].includes(symbol.type)) {
|
|
557
634
|
classNames.add(symbol.name);
|
|
558
635
|
}
|
|
559
636
|
}
|
|
@@ -561,7 +638,7 @@ class ProjectIndex {
|
|
|
561
638
|
|
|
562
639
|
for (const [filePath, fileEntry] of this.files) {
|
|
563
640
|
for (const symbol of fileEntry.symbols) {
|
|
564
|
-
if (!['class', 'interface', 'struct', 'trait'].includes(symbol.type)) {
|
|
641
|
+
if (!['class', 'interface', 'struct', 'trait', 'record'].includes(symbol.type)) {
|
|
565
642
|
continue;
|
|
566
643
|
}
|
|
567
644
|
|
|
@@ -866,12 +943,10 @@ class ProjectIndex {
|
|
|
866
943
|
}
|
|
867
944
|
}
|
|
868
945
|
candidate.importerCount = importerCount;
|
|
869
|
-
candidate.usageCount = this.countSymbolUsages(candidate.def).total;
|
|
870
946
|
}
|
|
871
|
-
// Sort by import popularity
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
);
|
|
947
|
+
// Sort by import popularity (cheap — no file reads needed)
|
|
948
|
+
// Skip usage count (expensive) — import popularity is a strong enough signal
|
|
949
|
+
tiedCandidates.sort((a, b) => b.importerCount - a.importerCount);
|
|
875
950
|
// Rebuild scored array: sorted tied candidates first, then rest
|
|
876
951
|
const rest = scored.filter(s => s.score !== tiedScore);
|
|
877
952
|
scored.length = 0;
|
|
@@ -1002,9 +1077,68 @@ class ProjectIndex {
|
|
|
1002
1077
|
* @param {object} symbol - Symbol with file, name, etc.
|
|
1003
1078
|
* @returns {object} { total, calls, definitions, imports, references }
|
|
1004
1079
|
*/
|
|
1005
|
-
countSymbolUsages(symbol) {
|
|
1080
|
+
countSymbolUsages(symbol, options = {}) {
|
|
1006
1081
|
const name = symbol.name;
|
|
1007
1082
|
const defFile = symbol.file;
|
|
1083
|
+
|
|
1084
|
+
// Fast path: use callee index + import graph for counting (no file reads)
|
|
1085
|
+
// This is an approximation — counts files containing calls, not individual call sites.
|
|
1086
|
+
// Use options.detailed = true for exact per-call-site counting via AST.
|
|
1087
|
+
if (!options.detailed) {
|
|
1088
|
+
// Ensure callee index is built (lazy, reused across operations)
|
|
1089
|
+
if (!this.calleeIndex) this.buildCalleeIndex();
|
|
1090
|
+
const hasFilters = options.exclude && options.exclude.length > 0;
|
|
1091
|
+
|
|
1092
|
+
// Count calls from callee index (files containing calls to this name)
|
|
1093
|
+
const calleeFiles = this.calleeIndex.get(name);
|
|
1094
|
+
let calls = 0;
|
|
1095
|
+
if (calleeFiles) {
|
|
1096
|
+
// Count actual call entries from calls cache for accuracy
|
|
1097
|
+
const { getCachedCalls } = require('./callers');
|
|
1098
|
+
for (const fp of calleeFiles) {
|
|
1099
|
+
// Apply exclude filters
|
|
1100
|
+
if (hasFilters) {
|
|
1101
|
+
const fe = this.files.get(fp);
|
|
1102
|
+
if (fe && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
|
|
1103
|
+
}
|
|
1104
|
+
const fileCalls = getCachedCalls(this, fp);
|
|
1105
|
+
if (!fileCalls) continue;
|
|
1106
|
+
for (const c of fileCalls) {
|
|
1107
|
+
if (c.name === name || c.resolvedName === name ||
|
|
1108
|
+
(c.resolvedNames && c.resolvedNames.includes(name))) {
|
|
1109
|
+
calls++;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Count definitions from symbol table
|
|
1116
|
+
const defs = this.symbols.get(name) || [];
|
|
1117
|
+
let definitions = defs.length;
|
|
1118
|
+
if (hasFilters) {
|
|
1119
|
+
definitions = defs.filter(d =>
|
|
1120
|
+
this.matchesFilters(d.relativePath, { exclude: options.exclude })
|
|
1121
|
+
).length;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Count imports from import graph (files that import from defFile and use this name)
|
|
1125
|
+
let imports = 0;
|
|
1126
|
+
const importers = this.exportGraph.get(defFile) || [];
|
|
1127
|
+
for (const importer of importers) {
|
|
1128
|
+
const fe = this.files.get(importer);
|
|
1129
|
+
if (!fe) continue;
|
|
1130
|
+
if (hasFilters && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
|
|
1131
|
+
// Check if this file's importNames reference our symbol
|
|
1132
|
+
if (fe.importNames && fe.importNames.includes(name)) {
|
|
1133
|
+
imports++;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const total = calls + definitions + imports;
|
|
1138
|
+
return { total, calls, definitions, imports, references: 0 };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Detailed path: full AST-based counting (original algorithm)
|
|
1008
1142
|
// Note: no 'g' flag - we only need to test for presence per line
|
|
1009
1143
|
const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
|
|
1010
1144
|
|
|
@@ -1029,8 +1163,8 @@ class ProjectIndex {
|
|
|
1029
1163
|
|
|
1030
1164
|
while (queue.length > 0) {
|
|
1031
1165
|
const file = queue.pop();
|
|
1032
|
-
const
|
|
1033
|
-
for (const importer of
|
|
1166
|
+
const importersArr = this.exportGraph.get(file) || [];
|
|
1167
|
+
for (const importer of importersArr) {
|
|
1034
1168
|
if (!relevantFiles.has(importer)) {
|
|
1035
1169
|
relevantFiles.add(importer);
|
|
1036
1170
|
// If this importer re-exports the symbol, follow its importers too
|
|
@@ -1062,8 +1196,12 @@ class ProjectIndex {
|
|
|
1062
1196
|
let imports = 0;
|
|
1063
1197
|
let references = 0;
|
|
1064
1198
|
|
|
1199
|
+
const hasExclude = options.exclude && options.exclude.length > 0;
|
|
1065
1200
|
for (const filePath of relevantFiles) {
|
|
1066
|
-
|
|
1201
|
+
const fileEntry = this.files.get(filePath);
|
|
1202
|
+
if (!fileEntry) continue;
|
|
1203
|
+
// Apply exclude filters (e.g., test file exclusion)
|
|
1204
|
+
if (hasExclude && !this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude })) continue;
|
|
1067
1205
|
|
|
1068
1206
|
try {
|
|
1069
1207
|
// Try AST-based counting first (with per-operation cache)
|
|
@@ -2534,6 +2672,97 @@ class ProjectIndex {
|
|
|
2534
2672
|
};
|
|
2535
2673
|
}
|
|
2536
2674
|
|
|
2675
|
+
/**
|
|
2676
|
+
* Detect circular dependencies in the import graph.
|
|
2677
|
+
* Uses DFS with 3-color marking to find all cycles.
|
|
2678
|
+
* @param {object} options - { file, exclude }
|
|
2679
|
+
* @returns {object} - { cycles, totalFiles, summary }
|
|
2680
|
+
*/
|
|
2681
|
+
circularDeps(options = {}) {
|
|
2682
|
+
this._beginOp();
|
|
2683
|
+
try {
|
|
2684
|
+
const exclude = options.exclude || [];
|
|
2685
|
+
const fileFilter = options.file || null;
|
|
2686
|
+
|
|
2687
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
2688
|
+
const color = new Map();
|
|
2689
|
+
const cycles = [];
|
|
2690
|
+
const stack = [];
|
|
2691
|
+
|
|
2692
|
+
const shouldSkip = (file) => {
|
|
2693
|
+
if (!this.files.has(file)) return true;
|
|
2694
|
+
if (exclude.length > 0) {
|
|
2695
|
+
const entry = this.files.get(file);
|
|
2696
|
+
if (entry && !this.matchesFilters(entry.relativePath, { exclude })) return true;
|
|
2697
|
+
}
|
|
2698
|
+
return false;
|
|
2699
|
+
};
|
|
2700
|
+
|
|
2701
|
+
const dfs = (file) => {
|
|
2702
|
+
color.set(file, GRAY);
|
|
2703
|
+
stack.push(file);
|
|
2704
|
+
|
|
2705
|
+
const neighbors = [...new Set(this.importGraph.get(file) || [])];
|
|
2706
|
+
|
|
2707
|
+
for (const neighbor of neighbors) {
|
|
2708
|
+
if (shouldSkip(neighbor)) continue;
|
|
2709
|
+
const nc = color.get(neighbor) || WHITE;
|
|
2710
|
+
if (nc === GRAY) {
|
|
2711
|
+
const idx = stack.indexOf(neighbor);
|
|
2712
|
+
cycles.push(stack.slice(idx));
|
|
2713
|
+
} else if (nc === WHITE) {
|
|
2714
|
+
dfs(neighbor);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
stack.pop();
|
|
2719
|
+
color.set(file, BLACK);
|
|
2720
|
+
};
|
|
2721
|
+
|
|
2722
|
+
for (const file of this.files.keys()) {
|
|
2723
|
+
if ((color.get(file) || WHITE) === WHITE && !shouldSkip(file)) {
|
|
2724
|
+
dfs(file);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// Convert to relative paths and deduplicate
|
|
2729
|
+
const seen = new Set();
|
|
2730
|
+
const uniqueCycles = [];
|
|
2731
|
+
for (const cycle of cycles) {
|
|
2732
|
+
const relCycle = cycle.map(f => this.files.get(f)?.relativePath || path.relative(this.root, f));
|
|
2733
|
+
// Normalize: rotate so lexicographically smallest file is first
|
|
2734
|
+
const sorted = relCycle.slice().sort();
|
|
2735
|
+
const minIdx = relCycle.indexOf(sorted[0]);
|
|
2736
|
+
const rotated = [...relCycle.slice(minIdx), ...relCycle.slice(0, minIdx)];
|
|
2737
|
+
const key = rotated.join('\0');
|
|
2738
|
+
if (!seen.has(key)) {
|
|
2739
|
+
seen.add(key);
|
|
2740
|
+
uniqueCycles.push({ files: rotated, length: rotated.length });
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// Filter by file pattern
|
|
2745
|
+
let result = uniqueCycles;
|
|
2746
|
+
if (fileFilter) {
|
|
2747
|
+
result = uniqueCycles.filter(c => c.files.some(f => f.includes(fileFilter)));
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
result.sort((a, b) => a.length - b.length || a.files[0].localeCompare(b.files[0]));
|
|
2751
|
+
|
|
2752
|
+
return {
|
|
2753
|
+
cycles: result,
|
|
2754
|
+
totalFiles: this.files.size,
|
|
2755
|
+
fileFilter: fileFilter || undefined,
|
|
2756
|
+
summary: {
|
|
2757
|
+
totalCycles: result.length,
|
|
2758
|
+
filesInCycles: new Set(result.flatMap(c => c.files)).size,
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2761
|
+
} finally {
|
|
2762
|
+
this._endOp();
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2537
2766
|
/**
|
|
2538
2767
|
* Detect patterns that may cause incomplete results
|
|
2539
2768
|
* Returns warnings about dynamic code patterns
|
|
@@ -3003,46 +3232,51 @@ class ProjectIndex {
|
|
|
3003
3232
|
}
|
|
3004
3233
|
this._clearTreeCache();
|
|
3005
3234
|
} else {
|
|
3006
|
-
|
|
3235
|
+
// Use findCallers (benefits from callee index) instead of usages() for speed
|
|
3236
|
+
const callerResults = this.findCallers(name, {
|
|
3237
|
+
includeMethods: false,
|
|
3238
|
+
includeUncertain: false,
|
|
3239
|
+
targetDefinitions: [def],
|
|
3240
|
+
});
|
|
3007
3241
|
const targetBindingId = def.bindingId;
|
|
3008
|
-
|
|
3009
|
-
|
|
3242
|
+
// Convert findCallers results to the format expected by analyzeCallSite
|
|
3243
|
+
const calls = callerResults.map(c => ({
|
|
3244
|
+
file: c.file,
|
|
3245
|
+
relativePath: c.relativePath,
|
|
3246
|
+
line: c.line,
|
|
3247
|
+
content: c.content,
|
|
3248
|
+
usageType: 'call',
|
|
3249
|
+
callerName: c.callerName,
|
|
3250
|
+
}));
|
|
3251
|
+
// Keep the same binding filter for backward compat (findCallers already handles this,
|
|
3252
|
+
// but cross-check with usages-based binding filter for safety)
|
|
3253
|
+
const filteredCalls = calls.filter(u => {
|
|
3010
3254
|
const fileEntry = this.files.get(u.file);
|
|
3011
|
-
if (fileEntry) {
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
if (fp !== u.file && path.dirname(fp) === dir) {
|
|
3020
|
-
const sibling = (fe.bindings || []).filter(b => b.name === name);
|
|
3021
|
-
localBindings = localBindings.concat(sibling);
|
|
3022
|
-
}
|
|
3255
|
+
if (fileEntry && targetBindingId) {
|
|
3256
|
+
let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
|
|
3257
|
+
if (localBindings.length === 0 && fileEntry.language === 'go') {
|
|
3258
|
+
const dir = path.dirname(u.file);
|
|
3259
|
+
for (const [fp, fe] of this.files) {
|
|
3260
|
+
if (fp !== u.file && path.dirname(fp) === dir) {
|
|
3261
|
+
const sibling = (fe.bindings || []).filter(b => b.name === name);
|
|
3262
|
+
localBindings = localBindings.concat(sibling);
|
|
3023
3263
|
}
|
|
3024
3264
|
}
|
|
3025
|
-
if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
|
|
3026
|
-
return false; // This file/package has its own definition — call is to that, not our target
|
|
3027
|
-
}
|
|
3028
3265
|
}
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
const parsedCalls = this.getCachedCalls(u.file);
|
|
3032
|
-
if (parsedCalls && Array.isArray(parsedCalls)) {
|
|
3033
|
-
const hasCall = parsedCalls.some(c => c.name === name && c.line === u.line);
|
|
3034
|
-
if (!hasCall) return false;
|
|
3266
|
+
if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
|
|
3267
|
+
return false;
|
|
3035
3268
|
}
|
|
3036
3269
|
}
|
|
3037
3270
|
return true;
|
|
3038
3271
|
});
|
|
3272
|
+
// (findCallers already handles binding resolution and scope-aware filtering)
|
|
3039
3273
|
|
|
3040
3274
|
// Analyze each call site, filtering out method calls for non-method definitions
|
|
3041
3275
|
callSites = [];
|
|
3042
3276
|
const defFileEntry = this.files.get(def.file);
|
|
3043
3277
|
const defLang = defFileEntry?.language;
|
|
3044
3278
|
const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
|
|
3045
|
-
for (const call of
|
|
3279
|
+
for (const call of filteredCalls) {
|
|
3046
3280
|
const analysis = this.analyzeCallSite(call, name);
|
|
3047
3281
|
// Skip method calls (obj.parse()) when target is a standalone function (parse())
|
|
3048
3282
|
// For Go, allow calls where receiver matches the package directory name
|
|
@@ -3065,7 +3299,7 @@ class ProjectIndex {
|
|
|
3065
3299
|
file: call.relativePath,
|
|
3066
3300
|
line: call.line,
|
|
3067
3301
|
expression: call.content.trim(),
|
|
3068
|
-
callerName: this.findEnclosingFunction(call.file, call.line),
|
|
3302
|
+
callerName: call.callerName || this.findEnclosingFunction(call.file, call.line),
|
|
3069
3303
|
...analysis
|
|
3070
3304
|
});
|
|
3071
3305
|
}
|
|
@@ -3134,6 +3368,450 @@ class ProjectIndex {
|
|
|
3134
3368
|
} finally { this._endOp(); }
|
|
3135
3369
|
}
|
|
3136
3370
|
|
|
3371
|
+
/**
|
|
3372
|
+
* Transitive blast radius — walk UP the caller chain recursively.
|
|
3373
|
+
* Answers: "What breaks transitively if I change this function?"
|
|
3374
|
+
*
|
|
3375
|
+
* @param {string} name - Function name
|
|
3376
|
+
* @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
|
|
3377
|
+
* @returns {object|null} Blast radius tree with summary
|
|
3378
|
+
*/
|
|
3379
|
+
blast(name, options = {}) {
|
|
3380
|
+
this._beginOp();
|
|
3381
|
+
try {
|
|
3382
|
+
const maxDepth = Math.max(0, options.depth ?? 3);
|
|
3383
|
+
const maxChildren = options.all ? Infinity : 10;
|
|
3384
|
+
const includeMethods = options.includeMethods ?? true;
|
|
3385
|
+
const includeUncertain = options.includeUncertain || false;
|
|
3386
|
+
const exclude = options.exclude || [];
|
|
3387
|
+
|
|
3388
|
+
const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
3389
|
+
if (!def) return null;
|
|
3390
|
+
|
|
3391
|
+
const visited = new Set();
|
|
3392
|
+
const affectedFunctions = new Set();
|
|
3393
|
+
const affectedFiles = new Set();
|
|
3394
|
+
let maxDepthReached = 0;
|
|
3395
|
+
|
|
3396
|
+
const buildCallerTree = (funcDef, currentDepth) => {
|
|
3397
|
+
const key = `${funcDef.file}:${funcDef.startLine}`;
|
|
3398
|
+
if (currentDepth > maxDepth) return null;
|
|
3399
|
+
if (visited.has(key)) {
|
|
3400
|
+
return {
|
|
3401
|
+
name: funcDef.name,
|
|
3402
|
+
file: funcDef.relativePath,
|
|
3403
|
+
line: funcDef.startLine,
|
|
3404
|
+
type: funcDef.type || 'function',
|
|
3405
|
+
children: [],
|
|
3406
|
+
alreadyShown: true
|
|
3407
|
+
};
|
|
3408
|
+
}
|
|
3409
|
+
visited.add(key);
|
|
3410
|
+
|
|
3411
|
+
if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
|
|
3412
|
+
if (currentDepth > 0) {
|
|
3413
|
+
affectedFunctions.add(key);
|
|
3414
|
+
affectedFiles.add(funcDef.file);
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
const node = {
|
|
3418
|
+
name: funcDef.name,
|
|
3419
|
+
file: funcDef.relativePath,
|
|
3420
|
+
line: funcDef.startLine,
|
|
3421
|
+
type: funcDef.type || 'function',
|
|
3422
|
+
children: []
|
|
3423
|
+
};
|
|
3424
|
+
|
|
3425
|
+
if (currentDepth < maxDepth) {
|
|
3426
|
+
const callers = this.findCallers(funcDef.name, {
|
|
3427
|
+
includeMethods,
|
|
3428
|
+
includeUncertain,
|
|
3429
|
+
targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
|
|
3430
|
+
});
|
|
3431
|
+
|
|
3432
|
+
// Deduplicate callers by enclosing function (multiple call sites → one tree node)
|
|
3433
|
+
const uniqueCallers = new Map();
|
|
3434
|
+
for (const c of callers) {
|
|
3435
|
+
if (!c.callerName) continue; // skip module-level code
|
|
3436
|
+
// Apply exclude filter
|
|
3437
|
+
if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
|
|
3438
|
+
const callerKey = c.callerStartLine
|
|
3439
|
+
? `${c.callerFile}:${c.callerStartLine}`
|
|
3440
|
+
: `${c.callerFile}:${c.callerName}`;
|
|
3441
|
+
if (!uniqueCallers.has(callerKey)) {
|
|
3442
|
+
uniqueCallers.set(callerKey, {
|
|
3443
|
+
name: c.callerName,
|
|
3444
|
+
file: c.callerFile,
|
|
3445
|
+
relativePath: c.relativePath,
|
|
3446
|
+
startLine: c.callerStartLine,
|
|
3447
|
+
endLine: c.callerEndLine,
|
|
3448
|
+
callSites: 1
|
|
3449
|
+
});
|
|
3450
|
+
} else {
|
|
3451
|
+
uniqueCallers.get(callerKey).callSites++;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// Resolve definitions and build child nodes
|
|
3456
|
+
const callerEntries = [];
|
|
3457
|
+
for (const [, caller] of uniqueCallers) {
|
|
3458
|
+
// Look up actual definition from symbol table
|
|
3459
|
+
const defs = this.symbols.get(caller.name);
|
|
3460
|
+
let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
|
|
3461
|
+
|
|
3462
|
+
if (!callerDef) {
|
|
3463
|
+
// Pseudo-definition for callers not in symbol table
|
|
3464
|
+
callerDef = {
|
|
3465
|
+
name: caller.name,
|
|
3466
|
+
file: caller.file,
|
|
3467
|
+
relativePath: caller.relativePath,
|
|
3468
|
+
startLine: caller.startLine,
|
|
3469
|
+
endLine: caller.endLine,
|
|
3470
|
+
type: 'function'
|
|
3471
|
+
};
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
callerEntries.push({ def: callerDef, callSites: caller.callSites });
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
// Stable sort by file + line
|
|
3478
|
+
callerEntries.sort((a, b) =>
|
|
3479
|
+
a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
|
|
3480
|
+
);
|
|
3481
|
+
|
|
3482
|
+
for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
|
|
3483
|
+
const childTree = buildCallerTree(cDef, currentDepth + 1);
|
|
3484
|
+
if (childTree) {
|
|
3485
|
+
childTree.callSites = callSites;
|
|
3486
|
+
node.children.push(childTree);
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
if (callerEntries.length > maxChildren) {
|
|
3491
|
+
node.truncatedChildren = callerEntries.length - maxChildren;
|
|
3492
|
+
// Count truncated callers in summary
|
|
3493
|
+
for (const { def: cDef } of callerEntries.slice(maxChildren)) {
|
|
3494
|
+
const key = `${cDef.file}:${cDef.startLine}`;
|
|
3495
|
+
if (!visited.has(key)) {
|
|
3496
|
+
affectedFunctions.add(key);
|
|
3497
|
+
affectedFiles.add(cDef.file);
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
return node;
|
|
3504
|
+
};
|
|
3505
|
+
|
|
3506
|
+
const tree = buildCallerTree(def, 0);
|
|
3507
|
+
|
|
3508
|
+
// Smart hints
|
|
3509
|
+
if (tree && tree.children.length === 0) {
|
|
3510
|
+
if (maxDepth === 0) {
|
|
3511
|
+
warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
|
|
3512
|
+
} else if (definitions.length > 1 && !options.file) {
|
|
3513
|
+
warnings.push({
|
|
3514
|
+
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.`
|
|
3515
|
+
});
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
return {
|
|
3520
|
+
root: name,
|
|
3521
|
+
file: def.relativePath,
|
|
3522
|
+
line: def.startLine,
|
|
3523
|
+
maxDepth,
|
|
3524
|
+
includeMethods,
|
|
3525
|
+
tree,
|
|
3526
|
+
summary: {
|
|
3527
|
+
totalAffected: affectedFunctions.size,
|
|
3528
|
+
totalFiles: affectedFiles.size,
|
|
3529
|
+
maxDepthReached
|
|
3530
|
+
},
|
|
3531
|
+
warnings: warnings.length > 0 ? warnings : undefined
|
|
3532
|
+
};
|
|
3533
|
+
} finally { this._endOp(); }
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
/**
|
|
3537
|
+
* Reverse trace: walk UP the caller chain to entry points.
|
|
3538
|
+
* Like blast but focused on "how does execution reach this function?"
|
|
3539
|
+
* Marks leaf nodes (functions with no callers) as entry points.
|
|
3540
|
+
*/
|
|
3541
|
+
reverseTrace(name, options = {}) {
|
|
3542
|
+
this._beginOp();
|
|
3543
|
+
try {
|
|
3544
|
+
const maxDepth = Math.max(0, options.depth ?? 5);
|
|
3545
|
+
const maxChildren = options.all ? Infinity : 10;
|
|
3546
|
+
const includeMethods = options.includeMethods ?? true;
|
|
3547
|
+
const includeUncertain = options.includeUncertain || false;
|
|
3548
|
+
const exclude = options.exclude || [];
|
|
3549
|
+
|
|
3550
|
+
const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
3551
|
+
if (!def) return null;
|
|
3552
|
+
|
|
3553
|
+
const visited = new Set();
|
|
3554
|
+
const entryPoints = [];
|
|
3555
|
+
let maxDepthReached = 0;
|
|
3556
|
+
|
|
3557
|
+
const buildCallerTree = (funcDef, currentDepth) => {
|
|
3558
|
+
const key = `${funcDef.file}:${funcDef.startLine}`;
|
|
3559
|
+
if (currentDepth > maxDepth) return null;
|
|
3560
|
+
if (visited.has(key)) {
|
|
3561
|
+
return {
|
|
3562
|
+
name: funcDef.name,
|
|
3563
|
+
file: funcDef.relativePath,
|
|
3564
|
+
line: funcDef.startLine,
|
|
3565
|
+
type: funcDef.type || 'function',
|
|
3566
|
+
children: [],
|
|
3567
|
+
alreadyShown: true
|
|
3568
|
+
};
|
|
3569
|
+
}
|
|
3570
|
+
visited.add(key);
|
|
3571
|
+
if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
|
|
3572
|
+
|
|
3573
|
+
const node = {
|
|
3574
|
+
name: funcDef.name,
|
|
3575
|
+
file: funcDef.relativePath,
|
|
3576
|
+
line: funcDef.startLine,
|
|
3577
|
+
type: funcDef.type || 'function',
|
|
3578
|
+
children: []
|
|
3579
|
+
};
|
|
3580
|
+
|
|
3581
|
+
if (currentDepth < maxDepth) {
|
|
3582
|
+
const callers = this.findCallers(funcDef.name, {
|
|
3583
|
+
includeMethods,
|
|
3584
|
+
includeUncertain,
|
|
3585
|
+
targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
|
|
3586
|
+
});
|
|
3587
|
+
|
|
3588
|
+
// Deduplicate callers by enclosing function
|
|
3589
|
+
const uniqueCallers = new Map();
|
|
3590
|
+
for (const c of callers) {
|
|
3591
|
+
if (!c.callerName) continue;
|
|
3592
|
+
if (exclude.length > 0 && !this.matchesFilters(c.relativePath, { exclude })) continue;
|
|
3593
|
+
const callerKey = c.callerStartLine
|
|
3594
|
+
? `${c.callerFile}:${c.callerStartLine}`
|
|
3595
|
+
: `${c.callerFile}:${c.callerName}`;
|
|
3596
|
+
if (!uniqueCallers.has(callerKey)) {
|
|
3597
|
+
uniqueCallers.set(callerKey, {
|
|
3598
|
+
name: c.callerName,
|
|
3599
|
+
file: c.callerFile,
|
|
3600
|
+
relativePath: c.relativePath,
|
|
3601
|
+
startLine: c.callerStartLine,
|
|
3602
|
+
endLine: c.callerEndLine,
|
|
3603
|
+
callSites: 1
|
|
3604
|
+
});
|
|
3605
|
+
} else {
|
|
3606
|
+
uniqueCallers.get(callerKey).callSites++;
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
// Resolve definitions and build child nodes
|
|
3611
|
+
const callerEntries = [];
|
|
3612
|
+
for (const [, caller] of uniqueCallers) {
|
|
3613
|
+
const defs = this.symbols.get(caller.name);
|
|
3614
|
+
let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
|
|
3615
|
+
if (!callerDef) {
|
|
3616
|
+
callerDef = {
|
|
3617
|
+
name: caller.name,
|
|
3618
|
+
file: caller.file,
|
|
3619
|
+
relativePath: caller.relativePath,
|
|
3620
|
+
startLine: caller.startLine,
|
|
3621
|
+
endLine: caller.endLine,
|
|
3622
|
+
type: 'function'
|
|
3623
|
+
};
|
|
3624
|
+
}
|
|
3625
|
+
callerEntries.push({ def: callerDef, callSites: caller.callSites });
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
callerEntries.sort((a, b) =>
|
|
3629
|
+
a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
|
|
3630
|
+
);
|
|
3631
|
+
|
|
3632
|
+
for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
|
|
3633
|
+
const childTree = buildCallerTree(cDef, currentDepth + 1);
|
|
3634
|
+
if (childTree) {
|
|
3635
|
+
childTree.callSites = callSites;
|
|
3636
|
+
node.children.push(childTree);
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
if (callerEntries.length > maxChildren) {
|
|
3641
|
+
node.truncatedChildren = callerEntries.length - maxChildren;
|
|
3642
|
+
// Count entry points in truncated branches so summary is accurate
|
|
3643
|
+
for (const { def: cDef } of callerEntries.slice(maxChildren)) {
|
|
3644
|
+
const key = `${cDef.file}:${cDef.startLine}`;
|
|
3645
|
+
if (!visited.has(key)) {
|
|
3646
|
+
const cCallers = this.findCallers(cDef.name, {
|
|
3647
|
+
includeMethods, includeUncertain,
|
|
3648
|
+
targetDefinitions: cDef.bindingId ? [cDef] : undefined,
|
|
3649
|
+
});
|
|
3650
|
+
if (cCallers.length === 0) {
|
|
3651
|
+
entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(this.root, cDef.file), line: cDef.startLine });
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
// Mark as entry point if no callers found (and not at depth limit)
|
|
3658
|
+
if (uniqueCallers.size === 0 && currentDepth > 0) {
|
|
3659
|
+
node.entryPoint = true;
|
|
3660
|
+
entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
return node;
|
|
3665
|
+
};
|
|
3666
|
+
|
|
3667
|
+
const tree = buildCallerTree(def, 0);
|
|
3668
|
+
|
|
3669
|
+
// Also mark root as entry point if it has no callers
|
|
3670
|
+
if (tree && tree.children.length === 0 && maxDepth > 0) {
|
|
3671
|
+
tree.entryPoint = true;
|
|
3672
|
+
entryPoints.push({ name: def.name, file: def.relativePath, line: def.startLine });
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
// Smart hints
|
|
3676
|
+
if (tree && tree.children.length === 0) {
|
|
3677
|
+
if (maxDepth === 0) {
|
|
3678
|
+
warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
|
|
3679
|
+
} else if (definitions.length > 1 && !options.file) {
|
|
3680
|
+
warnings.push({
|
|
3681
|
+
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.`
|
|
3682
|
+
});
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
return {
|
|
3687
|
+
root: name,
|
|
3688
|
+
file: def.relativePath,
|
|
3689
|
+
line: def.startLine,
|
|
3690
|
+
maxDepth,
|
|
3691
|
+
includeMethods,
|
|
3692
|
+
tree,
|
|
3693
|
+
entryPoints,
|
|
3694
|
+
summary: {
|
|
3695
|
+
totalEntryPoints: entryPoints.length,
|
|
3696
|
+
totalFunctions: visited.size - 1, // exclude root
|
|
3697
|
+
maxDepthReached
|
|
3698
|
+
},
|
|
3699
|
+
warnings: warnings.length > 0 ? warnings : undefined
|
|
3700
|
+
};
|
|
3701
|
+
} finally { this._endOp(); }
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
/**
|
|
3705
|
+
* Find tests affected by a change to the given function.
|
|
3706
|
+
* Composes blast() (transitive callers) with test file scanning.
|
|
3707
|
+
*/
|
|
3708
|
+
affectedTests(name, options = {}) {
|
|
3709
|
+
this._beginOp();
|
|
3710
|
+
try {
|
|
3711
|
+
// Step 1: Get all transitively affected functions via blast
|
|
3712
|
+
const blastResult = this.blast(name, {
|
|
3713
|
+
depth: options.depth ?? 3,
|
|
3714
|
+
file: options.file,
|
|
3715
|
+
className: options.className,
|
|
3716
|
+
all: true,
|
|
3717
|
+
exclude: options.exclude,
|
|
3718
|
+
includeMethods: options.includeMethods,
|
|
3719
|
+
includeUncertain: options.includeUncertain,
|
|
3720
|
+
});
|
|
3721
|
+
if (!blastResult) return null;
|
|
3722
|
+
|
|
3723
|
+
// Step 2: Collect all affected function names from the tree
|
|
3724
|
+
const affectedNames = new Set();
|
|
3725
|
+
affectedNames.add(name);
|
|
3726
|
+
const collectNames = (node) => {
|
|
3727
|
+
if (!node) return;
|
|
3728
|
+
affectedNames.add(node.name);
|
|
3729
|
+
for (const child of node.children || []) collectNames(child);
|
|
3730
|
+
};
|
|
3731
|
+
collectNames(blastResult.tree);
|
|
3732
|
+
|
|
3733
|
+
// Step 3: Build regex patterns for all names
|
|
3734
|
+
const namePatterns = new Map();
|
|
3735
|
+
for (const n of affectedNames) {
|
|
3736
|
+
const escaped = escapeRegExp(n);
|
|
3737
|
+
namePatterns.set(n, {
|
|
3738
|
+
regex: new RegExp('\\b' + escaped + '\\b'),
|
|
3739
|
+
callPattern: new RegExp(escaped + '\\s*\\('),
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
// Step 4: Scan test files once for all affected names
|
|
3744
|
+
const exclude = options.exclude;
|
|
3745
|
+
const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
|
|
3746
|
+
const results = [];
|
|
3747
|
+
for (const [filePath, fileEntry] of this.files) {
|
|
3748
|
+
if (!isTestFile(fileEntry.relativePath, fileEntry.language)) continue;
|
|
3749
|
+
if (excludeArr.length > 0 && !this.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
|
|
3750
|
+
try {
|
|
3751
|
+
const content = this._readFile(filePath);
|
|
3752
|
+
const lines = content.split('\n');
|
|
3753
|
+
const fileMatches = new Map();
|
|
3754
|
+
|
|
3755
|
+
lines.forEach((line, idx) => {
|
|
3756
|
+
for (const [funcName, patterns] of namePatterns) {
|
|
3757
|
+
if (patterns.regex.test(line)) {
|
|
3758
|
+
let matchType = 'reference';
|
|
3759
|
+
if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
|
|
3760
|
+
matchType = 'test-case';
|
|
3761
|
+
} else if (/\b(import|require|from)\b/.test(line)) {
|
|
3762
|
+
matchType = 'import';
|
|
3763
|
+
} else if (patterns.callPattern.test(line)) {
|
|
3764
|
+
matchType = 'call';
|
|
3765
|
+
}
|
|
3766
|
+
if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
|
|
3767
|
+
fileMatches.get(funcName).push({
|
|
3768
|
+
line: idx + 1, content: line.trim(),
|
|
3769
|
+
matchType, functionName: funcName
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
});
|
|
3774
|
+
|
|
3775
|
+
if (fileMatches.size > 0) {
|
|
3776
|
+
const coveredFunctions = [...fileMatches.keys()];
|
|
3777
|
+
const allMatches = [];
|
|
3778
|
+
for (const matches of fileMatches.values()) allMatches.push(...matches);
|
|
3779
|
+
allMatches.sort((a, b) => a.line - b.line);
|
|
3780
|
+
results.push({
|
|
3781
|
+
file: fileEntry.relativePath,
|
|
3782
|
+
coveredFunctions,
|
|
3783
|
+
matchCount: allMatches.length,
|
|
3784
|
+
matches: allMatches
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3787
|
+
} catch (e) { /* skip unreadable */ }
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
// Sort by coverage breadth then alphabetically
|
|
3791
|
+
results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
|
|
3792
|
+
|
|
3793
|
+
// Compute coverage stats
|
|
3794
|
+
const coveredSet = new Set();
|
|
3795
|
+
for (const r of results) for (const f of r.coveredFunctions) coveredSet.add(f);
|
|
3796
|
+
const uncovered = [...affectedNames].filter(n => !coveredSet.has(n));
|
|
3797
|
+
|
|
3798
|
+
return {
|
|
3799
|
+
root: blastResult.root, file: blastResult.file, line: blastResult.line,
|
|
3800
|
+
depth: blastResult.maxDepth,
|
|
3801
|
+
affectedFunctions: [...affectedNames],
|
|
3802
|
+
testFiles: results,
|
|
3803
|
+
summary: {
|
|
3804
|
+
totalAffected: affectedNames.size,
|
|
3805
|
+
totalTestFiles: results.length,
|
|
3806
|
+
coveredFunctions: coveredSet.size,
|
|
3807
|
+
uncoveredCount: uncovered.length,
|
|
3808
|
+
},
|
|
3809
|
+
uncovered,
|
|
3810
|
+
warnings: blastResult.warnings,
|
|
3811
|
+
};
|
|
3812
|
+
} finally { this._endOp(); }
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3137
3815
|
/** Plan a refactoring operation */
|
|
3138
3816
|
plan(name, options) { return verifyModule.plan(this, name, options); }
|
|
3139
3817
|
|
|
@@ -3222,18 +3900,10 @@ class ProjectIndex {
|
|
|
3222
3900
|
const isMethod = !!(primary.isMethod || primary.type === 'method' || primary.className);
|
|
3223
3901
|
const includeMethods = options.includeMethods ?? isMethod;
|
|
3224
3902
|
|
|
3225
|
-
// Get usage counts by type (
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
}
|
|
3230
|
-
const usages = this.usages(symbolName, usageOpts);
|
|
3231
|
-
const usagesByType = {
|
|
3232
|
-
definitions: usages.filter(u => u.isDefinition).length,
|
|
3233
|
-
calls: usages.filter(u => u.usageType === 'call').length,
|
|
3234
|
-
imports: usages.filter(u => u.usageType === 'import').length,
|
|
3235
|
-
references: usages.filter(u => u.usageType === 'reference').length
|
|
3236
|
-
};
|
|
3903
|
+
// Get usage counts by type (fast path uses callee index, no file reads)
|
|
3904
|
+
// Exclude test files by default (matching usages command behavior)
|
|
3905
|
+
const countExclude = !options.includeTests ? addTestExclusions(options.exclude) : options.exclude;
|
|
3906
|
+
const usagesByType = this.countSymbolUsages(primary, { exclude: countExclude });
|
|
3237
3907
|
|
|
3238
3908
|
// Get callers and callees (only for functions)
|
|
3239
3909
|
let callers = [];
|
|
@@ -3524,6 +4194,206 @@ class ProjectIndex {
|
|
|
3524
4194
|
} finally { this._endOp(); }
|
|
3525
4195
|
}
|
|
3526
4196
|
|
|
4197
|
+
/**
|
|
4198
|
+
* Structural search — query the symbol table and call index, not raw text.
|
|
4199
|
+
* Answers questions like "functions taking Request param", "all db.* calls",
|
|
4200
|
+
* "exported async functions", "decorated route handlers".
|
|
4201
|
+
*
|
|
4202
|
+
* @param {object} options
|
|
4203
|
+
* @param {string} [options.term] - Name filter (glob: * and ? supported)
|
|
4204
|
+
* @param {string} [options.type] - Symbol kind: function, class, call, method, type
|
|
4205
|
+
* @param {string} [options.param] - Parameter name or type substring
|
|
4206
|
+
* @param {string} [options.receiver] - Call receiver pattern (for type=call)
|
|
4207
|
+
* @param {string} [options.returns] - Return type substring
|
|
4208
|
+
* @param {string} [options.decorator] - Decorator/annotation name substring
|
|
4209
|
+
* @param {boolean} [options.exported] - Only exported symbols
|
|
4210
|
+
* @param {boolean} [options.unused] - Only symbols with zero callers
|
|
4211
|
+
* @param {string[]} [options.exclude] - Exclude file patterns
|
|
4212
|
+
* @param {string} [options.in] - Restrict to subdirectory
|
|
4213
|
+
* @param {string} [options.file] - File pattern filter
|
|
4214
|
+
* @param {number} [options.top] - Limit results
|
|
4215
|
+
* @returns {{ results: Array, meta: object }}
|
|
4216
|
+
*/
|
|
4217
|
+
structuralSearch(options = {}) {
|
|
4218
|
+
this._beginOp();
|
|
4219
|
+
try {
|
|
4220
|
+
const { term, param, receiver, returns: returnType, decorator, exported, unused } = options;
|
|
4221
|
+
// Auto-infer type: --receiver implies type=call
|
|
4222
|
+
const type = options.type || (receiver ? 'call' : undefined);
|
|
4223
|
+
const results = [];
|
|
4224
|
+
|
|
4225
|
+
// Validate type if provided
|
|
4226
|
+
if (type && !STRUCTURAL_TYPES.has(type)) {
|
|
4227
|
+
return {
|
|
4228
|
+
results: [],
|
|
4229
|
+
meta: {
|
|
4230
|
+
mode: 'structural',
|
|
4231
|
+
query: { type },
|
|
4232
|
+
totalMatched: 0,
|
|
4233
|
+
shown: 0,
|
|
4234
|
+
error: `Invalid type "${type}". Valid types: ${[...STRUCTURAL_TYPES].join(', ')}`,
|
|
4235
|
+
}
|
|
4236
|
+
};
|
|
4237
|
+
}
|
|
4238
|
+
|
|
4239
|
+
// Build glob-style name matcher from term
|
|
4240
|
+
const nameMatcher = term ? buildGlobMatcher(term, options.caseSensitive) : null;
|
|
4241
|
+
|
|
4242
|
+
// Helper: check if file passes filters
|
|
4243
|
+
const passesFileFilter = (fileEntry) => {
|
|
4244
|
+
if (!fileEntry) return false;
|
|
4245
|
+
if (options.file) {
|
|
4246
|
+
const rp = fileEntry.relativePath;
|
|
4247
|
+
if (!rp.includes(options.file) && !rp.endsWith(options.file)) return false;
|
|
4248
|
+
}
|
|
4249
|
+
if ((options.exclude && options.exclude.length > 0) || options.in) {
|
|
4250
|
+
if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) return false;
|
|
4251
|
+
}
|
|
4252
|
+
return true;
|
|
4253
|
+
};
|
|
4254
|
+
|
|
4255
|
+
if (type === 'call') {
|
|
4256
|
+
// Search call sites from callee index
|
|
4257
|
+
const { getCachedCalls } = require('./callers');
|
|
4258
|
+
const seenFiles = new Set();
|
|
4259
|
+
|
|
4260
|
+
// If term is given, only scan files that might contain that call
|
|
4261
|
+
if (term && !term.includes('*') && !term.includes('?')) {
|
|
4262
|
+
// Exact or substring — use callee index for fast lookup
|
|
4263
|
+
this.buildCalleeIndex();
|
|
4264
|
+
const files = this.calleeIndex.get(term);
|
|
4265
|
+
if (files) for (const f of files) seenFiles.add(f);
|
|
4266
|
+
} else {
|
|
4267
|
+
// Scan all files
|
|
4268
|
+
for (const fp of this.files.keys()) seenFiles.add(fp);
|
|
4269
|
+
}
|
|
4270
|
+
|
|
4271
|
+
for (const filePath of seenFiles) {
|
|
4272
|
+
const fileEntry = this.files.get(filePath);
|
|
4273
|
+
if (!passesFileFilter(fileEntry)) continue;
|
|
4274
|
+
const calls = getCachedCalls(this, filePath);
|
|
4275
|
+
if (!calls) continue;
|
|
4276
|
+
for (const call of calls) {
|
|
4277
|
+
if (nameMatcher && !nameMatcher(call.name)) continue;
|
|
4278
|
+
if (receiver) {
|
|
4279
|
+
if (!call.receiver) continue;
|
|
4280
|
+
if (!matchesSubstring(call.receiver, receiver, options.caseSensitive)) continue;
|
|
4281
|
+
}
|
|
4282
|
+
results.push({
|
|
4283
|
+
kind: 'call',
|
|
4284
|
+
name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
|
|
4285
|
+
file: fileEntry.relativePath,
|
|
4286
|
+
line: call.line,
|
|
4287
|
+
receiver: call.receiver || null,
|
|
4288
|
+
isMethod: call.isMethod || false,
|
|
4289
|
+
});
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
4292
|
+
} else {
|
|
4293
|
+
// Search symbols (functions, classes, methods, types)
|
|
4294
|
+
const functionTypes = new Set(['function', 'constructor', 'method', 'arrow', 'static', 'classmethod']);
|
|
4295
|
+
const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait']);
|
|
4296
|
+
const typeTypes = new Set(['type', 'enum', 'interface', 'trait']);
|
|
4297
|
+
const methodTypes = new Set(['method', 'constructor']);
|
|
4298
|
+
|
|
4299
|
+
for (const [symbolName, definitions] of this.symbols) {
|
|
4300
|
+
if (nameMatcher && !nameMatcher(symbolName)) continue;
|
|
4301
|
+
|
|
4302
|
+
for (const def of definitions) {
|
|
4303
|
+
// Type filter
|
|
4304
|
+
if (type === 'function' && !functionTypes.has(def.type)) continue;
|
|
4305
|
+
if (type === 'class' && !classTypes.has(def.type)) continue;
|
|
4306
|
+
if (type === 'method' && !methodTypes.has(def.type) && !def.isMethod) continue;
|
|
4307
|
+
if (type === 'type' && !typeTypes.has(def.type)) continue;
|
|
4308
|
+
|
|
4309
|
+
// File filters
|
|
4310
|
+
const fileEntry = this.files.get(def.file);
|
|
4311
|
+
if (!passesFileFilter(fileEntry)) continue;
|
|
4312
|
+
|
|
4313
|
+
// Param filter: match param name or type
|
|
4314
|
+
if (param) {
|
|
4315
|
+
const cs = options.caseSensitive;
|
|
4316
|
+
const ps = def.paramsStructured || [];
|
|
4317
|
+
const paramStr = def.params || '';
|
|
4318
|
+
const hasMatch = ps.some(p =>
|
|
4319
|
+
matchesSubstring(p.name, param, cs) ||
|
|
4320
|
+
(p.type && matchesSubstring(p.type, param, cs))
|
|
4321
|
+
) || matchesSubstring(paramStr, param, cs);
|
|
4322
|
+
if (!hasMatch) continue;
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
// Return type filter
|
|
4326
|
+
if (returnType) {
|
|
4327
|
+
if (!def.returnType || !matchesSubstring(def.returnType, returnType, options.caseSensitive)) continue;
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
// Decorator filter: checks decorators (Python), modifiers (Java annotations stored lowercase)
|
|
4331
|
+
if (decorator) {
|
|
4332
|
+
const cs = options.caseSensitive;
|
|
4333
|
+
const hasDecorator = (def.decorators && def.decorators.some(d => matchesSubstring(d, decorator, cs))) ||
|
|
4334
|
+
(def.modifiers && def.modifiers.some(m => matchesSubstring(m, decorator, cs)));
|
|
4335
|
+
if (!hasDecorator) continue;
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
// Exported filter
|
|
4339
|
+
if (exported) {
|
|
4340
|
+
const mods = def.modifiers || [];
|
|
4341
|
+
const isExp = (fileEntry && fileEntry.exports.includes(symbolName)) ||
|
|
4342
|
+
mods.includes('export') || mods.includes('public') ||
|
|
4343
|
+
mods.some(m => m.startsWith('pub')) ||
|
|
4344
|
+
(fileEntry && fileEntry.language === 'go' && /^[A-Z]/.test(symbolName));
|
|
4345
|
+
if (!isExp) continue;
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
// Unused filter (expensive — last check)
|
|
4349
|
+
if (unused) {
|
|
4350
|
+
this.buildCalleeIndex();
|
|
4351
|
+
if (this.calleeIndex.has(symbolName)) continue;
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
// Merge decorators from both Python-style decorators and Java-style modifiers
|
|
4355
|
+
const allDecorators = def.decorators || null;
|
|
4356
|
+
|
|
4357
|
+
results.push({
|
|
4358
|
+
kind: def.type,
|
|
4359
|
+
name: symbolName,
|
|
4360
|
+
file: def.relativePath,
|
|
4361
|
+
line: def.startLine,
|
|
4362
|
+
params: def.params || null,
|
|
4363
|
+
returnType: def.returnType || null,
|
|
4364
|
+
decorators: allDecorators,
|
|
4365
|
+
className: def.className || null,
|
|
4366
|
+
exported: exported ? true : undefined,
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
// Sort by file, then line
|
|
4373
|
+
results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
4374
|
+
|
|
4375
|
+
// Apply top limit
|
|
4376
|
+
const total = results.length;
|
|
4377
|
+
const top = options.top;
|
|
4378
|
+
if (top && top > 0 && results.length > top) {
|
|
4379
|
+
results.length = top;
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4382
|
+
return {
|
|
4383
|
+
results,
|
|
4384
|
+
meta: {
|
|
4385
|
+
mode: 'structural',
|
|
4386
|
+
query: Object.fromEntries(Object.entries({
|
|
4387
|
+
type: type || 'any', term, param, receiver, returns: returnType,
|
|
4388
|
+
decorator, exported: exported || undefined, unused: unused || undefined,
|
|
4389
|
+
}).filter(([, v]) => v !== undefined && v !== null)),
|
|
4390
|
+
totalMatched: total,
|
|
4391
|
+
shown: results.length,
|
|
4392
|
+
}
|
|
4393
|
+
};
|
|
4394
|
+
} finally { this._endOp(); }
|
|
4395
|
+
}
|
|
4396
|
+
|
|
3527
4397
|
// ========================================================================
|
|
3528
4398
|
// PROJECT INFO
|
|
3529
4399
|
// ========================================================================
|
|
@@ -3573,7 +4443,8 @@ class ProjectIndex {
|
|
|
3573
4443
|
for (const [name, symbols] of this.symbols) {
|
|
3574
4444
|
for (const sym of symbols) {
|
|
3575
4445
|
if (sym.type === 'function' || sym.type === 'method' || sym.type === 'static' ||
|
|
3576
|
-
sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract'
|
|
4446
|
+
sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract' ||
|
|
4447
|
+
sym.type === 'classmethod') {
|
|
3577
4448
|
const lineCount = sym.endLine - sym.startLine + 1;
|
|
3578
4449
|
const relativePath = sym.relativePath || (sym.file ? path.relative(this.root, sym.file) : '');
|
|
3579
4450
|
functions.push({
|
|
@@ -3638,10 +4509,11 @@ class ProjectIndex {
|
|
|
3638
4509
|
if (fileFilter && !fileFilter.has(filePath)) continue;
|
|
3639
4510
|
let functions = fileEntry.symbols.filter(s =>
|
|
3640
4511
|
s.type === 'function' || s.type === 'method' || s.type === 'static' ||
|
|
3641
|
-
s.type === 'constructor' || s.type === 'public' || s.type === 'abstract'
|
|
4512
|
+
s.type === 'constructor' || s.type === 'public' || s.type === 'abstract' ||
|
|
4513
|
+
s.type === 'classmethod'
|
|
3642
4514
|
);
|
|
3643
4515
|
const classes = fileEntry.symbols.filter(s =>
|
|
3644
|
-
['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl'].includes(s.type)
|
|
4516
|
+
['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl', 'record', 'namespace'].includes(s.type)
|
|
3645
4517
|
);
|
|
3646
4518
|
const state = fileEntry.symbols.filter(s => s.type === 'state');
|
|
3647
4519
|
|