ucn 3.7.5 → 3.7.7
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/cli/index.js +181 -100
- package/core/discovery.js +8 -2
- package/core/output.js +172 -2
- package/core/project.js +121 -38
- package/core/shared.js +43 -0
- package/languages/go.js +61 -1
- package/languages/java.js +78 -2
- package/languages/rust.js +71 -2
- package/mcp/server.js +8 -26
- package/package.json +1 -1
- package/test/mcp-edge-cases.js +28 -0
- package/test/parser.test.js +534 -9
package/core/project.js
CHANGED
|
@@ -21,6 +21,9 @@ const UCN_VERSION = require('../package.json').version;
|
|
|
21
21
|
// Lazy-initialized per-language keyword sets (populated on first isKeyword call)
|
|
22
22
|
let LANGUAGE_KEYWORDS = null;
|
|
23
23
|
|
|
24
|
+
// Symbol types that are not callable (used to filter class/struct/type declarations from call analysis)
|
|
25
|
+
const NON_CALLABLE_TYPES = new Set(['class', 'struct', 'interface', 'type', 'enum', 'trait', 'state', 'impl']);
|
|
26
|
+
|
|
24
27
|
/**
|
|
25
28
|
* Escape special regex characters
|
|
26
29
|
*/
|
|
@@ -48,6 +51,35 @@ class ProjectIndex {
|
|
|
48
51
|
this.buildTime = null;
|
|
49
52
|
this.callsCache = new Map(); // filePath -> { mtime, hash, calls, content }
|
|
50
53
|
this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
|
|
54
|
+
this._opContentCache = null; // per-operation file content cache (Map<filePath, string>)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read file content with per-operation caching.
|
|
59
|
+
* When an operation cache is active (_opContentCache is set), reads are
|
|
60
|
+
* cached for the duration of the operation to avoid redundant disk I/O.
|
|
61
|
+
*/
|
|
62
|
+
_readFile(filePath) {
|
|
63
|
+
if (this._opContentCache) {
|
|
64
|
+
const cached = this._opContentCache.get(filePath);
|
|
65
|
+
if (cached !== undefined) return cached;
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
this._opContentCache.set(filePath, content);
|
|
68
|
+
return content;
|
|
69
|
+
}
|
|
70
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Start a per-operation content cache scope */
|
|
74
|
+
_beginOp() {
|
|
75
|
+
if (!this._opContentCache) {
|
|
76
|
+
this._opContentCache = new Map();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** End a per-operation content cache scope */
|
|
81
|
+
_endOp() {
|
|
82
|
+
this._opContentCache = null;
|
|
51
83
|
}
|
|
52
84
|
|
|
53
85
|
/**
|
|
@@ -507,8 +539,14 @@ class ProjectIndex {
|
|
|
507
539
|
if (filters.exclude && filters.exclude.length > 0) {
|
|
508
540
|
const lowerPath = filePath.toLowerCase();
|
|
509
541
|
for (const pattern of filters.exclude) {
|
|
510
|
-
const
|
|
511
|
-
|
|
542
|
+
const lowerPattern = pattern.toLowerCase();
|
|
543
|
+
let regex = this._excludeRegexCache?.get(lowerPattern);
|
|
544
|
+
if (!regex) {
|
|
545
|
+
const escaped = lowerPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
546
|
+
regex = new RegExp(`(^|[/._\\-])${escaped}s?([/._\\-]|$)`);
|
|
547
|
+
if (!this._excludeRegexCache) this._excludeRegexCache = new Map();
|
|
548
|
+
this._excludeRegexCache.set(lowerPattern, regex);
|
|
549
|
+
}
|
|
512
550
|
if (regex.test(lowerPath)) {
|
|
513
551
|
return false;
|
|
514
552
|
}
|
|
@@ -743,7 +781,7 @@ class ProjectIndex {
|
|
|
743
781
|
if (!this.files.has(filePath)) continue;
|
|
744
782
|
|
|
745
783
|
try {
|
|
746
|
-
const content =
|
|
784
|
+
const content = this._readFile(filePath);
|
|
747
785
|
|
|
748
786
|
// Try AST-based counting first
|
|
749
787
|
const language = detectLanguage(filePath);
|
|
@@ -803,6 +841,8 @@ class ProjectIndex {
|
|
|
803
841
|
* @returns {Array} Usages grouped as definitions, calls, imports, references
|
|
804
842
|
*/
|
|
805
843
|
usages(name, options = {}) {
|
|
844
|
+
this._beginOp();
|
|
845
|
+
try {
|
|
806
846
|
const usages = [];
|
|
807
847
|
|
|
808
848
|
// Get definitions (filtered)
|
|
@@ -829,7 +869,7 @@ class ProjectIndex {
|
|
|
829
869
|
}
|
|
830
870
|
|
|
831
871
|
try {
|
|
832
|
-
const content =
|
|
872
|
+
const content = this._readFile(filePath);
|
|
833
873
|
const lines = content.split('\n');
|
|
834
874
|
|
|
835
875
|
// Try AST-based detection first
|
|
@@ -947,6 +987,7 @@ class ProjectIndex {
|
|
|
947
987
|
}
|
|
948
988
|
}
|
|
949
989
|
return deduped;
|
|
990
|
+
} finally { this._endOp(); }
|
|
950
991
|
}
|
|
951
992
|
|
|
952
993
|
/**
|
|
@@ -1002,6 +1043,8 @@ class ProjectIndex {
|
|
|
1002
1043
|
* Get context for a symbol (callers + callees)
|
|
1003
1044
|
*/
|
|
1004
1045
|
context(name, options = {}) {
|
|
1046
|
+
this._beginOp();
|
|
1047
|
+
try {
|
|
1005
1048
|
const resolved = this.resolveSymbol(name, { file: options.file });
|
|
1006
1049
|
let { def, definitions, warnings } = resolved;
|
|
1007
1050
|
if (!def) {
|
|
@@ -1083,6 +1126,7 @@ class ProjectIndex {
|
|
|
1083
1126
|
}
|
|
1084
1127
|
|
|
1085
1128
|
return result;
|
|
1129
|
+
} finally { this._endOp(); }
|
|
1086
1130
|
}
|
|
1087
1131
|
|
|
1088
1132
|
/**
|
|
@@ -1105,14 +1149,14 @@ class ProjectIndex {
|
|
|
1105
1149
|
// mtime matches - cache is likely valid
|
|
1106
1150
|
if (options.includeContent) {
|
|
1107
1151
|
// Need content, read if not cached
|
|
1108
|
-
const content = cached.content ||
|
|
1152
|
+
const content = cached.content || this._readFile(filePath);
|
|
1109
1153
|
return { calls: cached.calls, content };
|
|
1110
1154
|
}
|
|
1111
1155
|
return cached.calls;
|
|
1112
1156
|
}
|
|
1113
1157
|
|
|
1114
1158
|
// mtime changed or no cache - need to read and possibly reparse
|
|
1115
|
-
const content =
|
|
1159
|
+
const content = this._readFile(filePath);
|
|
1116
1160
|
const hash = crypto.createHash('md5').update(content).digest('hex');
|
|
1117
1161
|
|
|
1118
1162
|
// Check if content actually changed (mtime can change without content change)
|
|
@@ -1159,6 +1203,8 @@ class ProjectIndex {
|
|
|
1159
1203
|
* @param {boolean} [options.includeMethods] - Include method calls (default: false)
|
|
1160
1204
|
*/
|
|
1161
1205
|
findCallers(name, options = {}) {
|
|
1206
|
+
this._beginOp();
|
|
1207
|
+
try {
|
|
1162
1208
|
const callers = [];
|
|
1163
1209
|
const stats = options.stats;
|
|
1164
1210
|
|
|
@@ -1418,6 +1464,7 @@ class ProjectIndex {
|
|
|
1418
1464
|
}
|
|
1419
1465
|
|
|
1420
1466
|
return callers;
|
|
1467
|
+
} finally { this._endOp(); }
|
|
1421
1468
|
}
|
|
1422
1469
|
|
|
1423
1470
|
/**
|
|
@@ -1471,6 +1518,8 @@ class ProjectIndex {
|
|
|
1471
1518
|
* @param {boolean} [options.includeMethods] - Include method calls (default: false)
|
|
1472
1519
|
*/
|
|
1473
1520
|
findCallees(def, options = {}) {
|
|
1521
|
+
this._beginOp();
|
|
1522
|
+
try {
|
|
1474
1523
|
try {
|
|
1475
1524
|
// Get all calls from the file's cache (now includes enclosingFunction)
|
|
1476
1525
|
const calls = this.getCachedCalls(def.file);
|
|
@@ -1484,9 +1533,8 @@ class ProjectIndex {
|
|
|
1484
1533
|
// Only class methods are excluded — they are independently addressable symbols.
|
|
1485
1534
|
// Calls within closures (named functions without className) ARE included as
|
|
1486
1535
|
// callees of the parent function, since closures are part of the parent's behavior.
|
|
1487
|
-
const nonCallableTypes = new Set(['class', 'struct', 'interface', 'type', 'enum', 'trait', 'state', 'impl']);
|
|
1488
1536
|
const innerSymbolRanges = fileEntry ? fileEntry.symbols
|
|
1489
|
-
.filter(s => !
|
|
1537
|
+
.filter(s => !NON_CALLABLE_TYPES.has(s.type) &&
|
|
1490
1538
|
s.className && // Only exclude class methods, not closures
|
|
1491
1539
|
s.startLine > def.startLine && s.endLine <= def.endLine &&
|
|
1492
1540
|
s.startLine !== def.startLine)
|
|
@@ -1853,6 +1901,7 @@ class ProjectIndex {
|
|
|
1853
1901
|
// Return empty callees rather than crashing the entire query.
|
|
1854
1902
|
return [];
|
|
1855
1903
|
}
|
|
1904
|
+
} finally { this._endOp(); }
|
|
1856
1905
|
}
|
|
1857
1906
|
|
|
1858
1907
|
/**
|
|
@@ -1869,6 +1918,8 @@ class ProjectIndex {
|
|
|
1869
1918
|
* Smart extraction: function + dependencies
|
|
1870
1919
|
*/
|
|
1871
1920
|
smart(name, options = {}) {
|
|
1921
|
+
this._beginOp();
|
|
1922
|
+
try {
|
|
1872
1923
|
const { def } = this.resolveSymbol(name, { file: options.file });
|
|
1873
1924
|
if (!def) {
|
|
1874
1925
|
return null;
|
|
@@ -1929,6 +1980,7 @@ class ProjectIndex {
|
|
|
1929
1980
|
uncertain: stats.uncertain
|
|
1930
1981
|
}
|
|
1931
1982
|
};
|
|
1983
|
+
} finally { this._endOp(); }
|
|
1932
1984
|
}
|
|
1933
1985
|
|
|
1934
1986
|
// ========================================================================
|
|
@@ -1940,7 +1992,7 @@ class ProjectIndex {
|
|
|
1940
1992
|
*/
|
|
1941
1993
|
getLineContent(filePath, lineNum) {
|
|
1942
1994
|
try {
|
|
1943
|
-
const content =
|
|
1995
|
+
const content = this._readFile(filePath);
|
|
1944
1996
|
const lines = content.split('\n');
|
|
1945
1997
|
return lines[lineNum - 1] || '';
|
|
1946
1998
|
} catch (e) {
|
|
@@ -1953,7 +2005,7 @@ class ProjectIndex {
|
|
|
1953
2005
|
*/
|
|
1954
2006
|
extractCode(symbol) {
|
|
1955
2007
|
try {
|
|
1956
|
-
const content =
|
|
2008
|
+
const content = this._readFile(symbol.file);
|
|
1957
2009
|
const lines = content.split('\n');
|
|
1958
2010
|
const extracted = lines.slice(symbol.startLine - 1, symbol.endLine);
|
|
1959
2011
|
cleanHtmlScriptTags(extracted, detectLanguage(symbol.file));
|
|
@@ -2113,10 +2165,9 @@ class ProjectIndex {
|
|
|
2113
2165
|
const fileEntry = this.files.get(filePath);
|
|
2114
2166
|
if (!fileEntry) return null;
|
|
2115
2167
|
|
|
2116
|
-
const nonCallableTypes = new Set(['class', 'struct', 'interface', 'type', 'state', 'impl', 'enum', 'trait']);
|
|
2117
2168
|
let best = null;
|
|
2118
2169
|
for (const symbol of fileEntry.symbols) {
|
|
2119
|
-
if (!
|
|
2170
|
+
if (!NON_CALLABLE_TYPES.has(symbol.type) &&
|
|
2120
2171
|
symbol.startLine <= lineNum &&
|
|
2121
2172
|
symbol.endLine >= lineNum) {
|
|
2122
2173
|
if (!best || (symbol.endLine - symbol.startLine) < (best.endLine - best.startLine)) {
|
|
@@ -2145,7 +2196,7 @@ class ProjectIndex {
|
|
|
2145
2196
|
if (!langModule?.findInstanceAttributeTypes) return null;
|
|
2146
2197
|
|
|
2147
2198
|
try {
|
|
2148
|
-
const content =
|
|
2199
|
+
const content = this._readFile(filePath);
|
|
2149
2200
|
const parser = getParser('python');
|
|
2150
2201
|
fileCache = langModule.findInstanceAttributeTypes(content, parser);
|
|
2151
2202
|
this._attrTypeCache.set(filePath, fileCache);
|
|
@@ -2203,7 +2254,7 @@ class ProjectIndex {
|
|
|
2203
2254
|
}
|
|
2204
2255
|
|
|
2205
2256
|
try {
|
|
2206
|
-
const content =
|
|
2257
|
+
const content = this._readFile(normalizedPath);
|
|
2207
2258
|
const { imports: rawImports } = extractImports(content, fileEntry.language);
|
|
2208
2259
|
|
|
2209
2260
|
return rawImports.map(imp => {
|
|
@@ -2287,7 +2338,7 @@ class ProjectIndex {
|
|
|
2287
2338
|
// Find the import line
|
|
2288
2339
|
let importLine = null;
|
|
2289
2340
|
try {
|
|
2290
|
-
const content =
|
|
2341
|
+
const content = this._readFile(importerPath);
|
|
2291
2342
|
const lines = content.split('\n');
|
|
2292
2343
|
let targetBasename = path.basename(targetPath, path.extname(targetPath));
|
|
2293
2344
|
|
|
@@ -2336,6 +2387,8 @@ class ProjectIndex {
|
|
|
2336
2387
|
* @returns {Array} Test files and matches
|
|
2337
2388
|
*/
|
|
2338
2389
|
tests(nameOrFile, options = {}) {
|
|
2390
|
+
this._beginOp();
|
|
2391
|
+
try {
|
|
2339
2392
|
const results = [];
|
|
2340
2393
|
|
|
2341
2394
|
// Check if it's a file path
|
|
@@ -2365,7 +2418,7 @@ class ProjectIndex {
|
|
|
2365
2418
|
|
|
2366
2419
|
for (const { path: testPath, entry } of testFiles) {
|
|
2367
2420
|
try {
|
|
2368
|
-
const content =
|
|
2421
|
+
const content = this._readFile(testPath);
|
|
2369
2422
|
const lines = content.split('\n');
|
|
2370
2423
|
const matches = [];
|
|
2371
2424
|
|
|
@@ -2409,6 +2462,7 @@ class ProjectIndex {
|
|
|
2409
2462
|
}
|
|
2410
2463
|
|
|
2411
2464
|
return results;
|
|
2465
|
+
} finally { this._endOp(); }
|
|
2412
2466
|
}
|
|
2413
2467
|
|
|
2414
2468
|
/**
|
|
@@ -2660,7 +2714,7 @@ class ProjectIndex {
|
|
|
2660
2714
|
|
|
2661
2715
|
for (const [filePath, fileEntry] of this.files) {
|
|
2662
2716
|
try {
|
|
2663
|
-
const content =
|
|
2717
|
+
const content = this._readFile(filePath);
|
|
2664
2718
|
const language = detectLanguage(filePath);
|
|
2665
2719
|
if (!language) continue;
|
|
2666
2720
|
|
|
@@ -2699,7 +2753,7 @@ class ProjectIndex {
|
|
|
2699
2753
|
const language = detectLanguage(filePath);
|
|
2700
2754
|
if (!language) continue;
|
|
2701
2755
|
|
|
2702
|
-
const content =
|
|
2756
|
+
const content = this._readFile(filePath);
|
|
2703
2757
|
|
|
2704
2758
|
// For HTML files, parse the virtual JS content instead of raw HTML
|
|
2705
2759
|
// (HTML tree-sitter sees script content as raw_text, not JS identifiers)
|
|
@@ -2776,6 +2830,8 @@ class ProjectIndex {
|
|
|
2776
2830
|
* @returns {Array} Unused symbols
|
|
2777
2831
|
*/
|
|
2778
2832
|
deadcode(options = {}) {
|
|
2833
|
+
this._beginOp();
|
|
2834
|
+
try {
|
|
2779
2835
|
const results = [];
|
|
2780
2836
|
let excludedDecorated = 0;
|
|
2781
2837
|
let excludedExported = 0;
|
|
@@ -2967,6 +3023,7 @@ class ProjectIndex {
|
|
|
2967
3023
|
results.excludedExported = excludedExported;
|
|
2968
3024
|
|
|
2969
3025
|
return results;
|
|
3026
|
+
} finally { this._endOp(); }
|
|
2970
3027
|
}
|
|
2971
3028
|
|
|
2972
3029
|
/**
|
|
@@ -3069,7 +3126,7 @@ class ProjectIndex {
|
|
|
3069
3126
|
if (filePath.includes('node_modules')) continue;
|
|
3070
3127
|
|
|
3071
3128
|
try {
|
|
3072
|
-
const content =
|
|
3129
|
+
const content = this._readFile(filePath);
|
|
3073
3130
|
|
|
3074
3131
|
// Dynamic imports: import(), require(variable), __import__
|
|
3075
3132
|
dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
|
|
@@ -3130,6 +3187,8 @@ class ProjectIndex {
|
|
|
3130
3187
|
* @returns {object} Related functions grouped by relationship type
|
|
3131
3188
|
*/
|
|
3132
3189
|
related(name, options = {}) {
|
|
3190
|
+
this._beginOp();
|
|
3191
|
+
try {
|
|
3133
3192
|
const { def } = this.resolveSymbol(name, { file: options.file });
|
|
3134
3193
|
if (!def) {
|
|
3135
3194
|
return null;
|
|
@@ -3220,19 +3279,20 @@ class ProjectIndex {
|
|
|
3220
3279
|
}
|
|
3221
3280
|
|
|
3222
3281
|
// 4. Shared callees - functions that call the same things
|
|
3282
|
+
// Optimized: instead of computing callees for every symbol (O(N*M)),
|
|
3283
|
+
// find who else calls each of our callees (O(K) where K = our callee count)
|
|
3223
3284
|
if (def.type === 'function' || def.params !== undefined) {
|
|
3224
|
-
const myCallees =
|
|
3225
|
-
|
|
3285
|
+
const myCallees = this.findCallees(def);
|
|
3286
|
+
const myCalleeNames = new Set(myCallees.map(c => c.name));
|
|
3287
|
+
if (myCalleeNames.size > 0) {
|
|
3226
3288
|
const calleeCounts = new Map();
|
|
3227
|
-
for (const
|
|
3228
|
-
|
|
3229
|
-
const
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
if (shared.length > 0) {
|
|
3235
|
-
calleeCounts.set(symName, shared.length);
|
|
3289
|
+
for (const calleeName of myCalleeNames) {
|
|
3290
|
+
// Find other functions that also call this callee
|
|
3291
|
+
const callers = this.findCallers(calleeName);
|
|
3292
|
+
for (const caller of callers) {
|
|
3293
|
+
if (caller.callerName && caller.callerName !== name) {
|
|
3294
|
+
calleeCounts.set(caller.callerName, (calleeCounts.get(caller.callerName) || 0) + 1);
|
|
3295
|
+
}
|
|
3236
3296
|
}
|
|
3237
3297
|
}
|
|
3238
3298
|
// Sort by shared callee count
|
|
@@ -3254,6 +3314,7 @@ class ProjectIndex {
|
|
|
3254
3314
|
}
|
|
3255
3315
|
|
|
3256
3316
|
return related;
|
|
3317
|
+
} finally { this._endOp(); }
|
|
3257
3318
|
}
|
|
3258
3319
|
|
|
3259
3320
|
/**
|
|
@@ -3265,6 +3326,8 @@ class ProjectIndex {
|
|
|
3265
3326
|
* @returns {object} Call tree structure
|
|
3266
3327
|
*/
|
|
3267
3328
|
trace(name, options = {}) {
|
|
3329
|
+
this._beginOp();
|
|
3330
|
+
try {
|
|
3268
3331
|
// Sanitize depth: use default for null/undefined, clamp negative to 0
|
|
3269
3332
|
const rawDepth = options.depth ?? 3;
|
|
3270
3333
|
const maxDepth = Math.max(0, rawDepth);
|
|
@@ -3353,6 +3416,7 @@ class ProjectIndex {
|
|
|
3353
3416
|
truncatedCallers: truncatedCallers > 0 ? truncatedCallers : undefined,
|
|
3354
3417
|
warnings: warnings.length > 0 ? warnings : undefined
|
|
3355
3418
|
};
|
|
3419
|
+
} finally { this._endOp(); }
|
|
3356
3420
|
}
|
|
3357
3421
|
|
|
3358
3422
|
/**
|
|
@@ -3364,6 +3428,8 @@ class ProjectIndex {
|
|
|
3364
3428
|
* @returns {object} Impact analysis
|
|
3365
3429
|
*/
|
|
3366
3430
|
impact(name, options = {}) {
|
|
3431
|
+
this._beginOp();
|
|
3432
|
+
try {
|
|
3367
3433
|
const { def } = this.resolveSymbol(name, { file: options.file });
|
|
3368
3434
|
if (!def) {
|
|
3369
3435
|
return null;
|
|
@@ -3454,6 +3520,7 @@ class ProjectIndex {
|
|
|
3454
3520
|
})),
|
|
3455
3521
|
patterns
|
|
3456
3522
|
};
|
|
3523
|
+
} finally { this._endOp(); }
|
|
3457
3524
|
}
|
|
3458
3525
|
|
|
3459
3526
|
/**
|
|
@@ -3463,6 +3530,8 @@ class ProjectIndex {
|
|
|
3463
3530
|
* @returns {object} Plan with before/after signatures and affected call sites
|
|
3464
3531
|
*/
|
|
3465
3532
|
plan(name, options = {}) {
|
|
3533
|
+
this._beginOp();
|
|
3534
|
+
try {
|
|
3466
3535
|
const definitions = this.symbols.get(name);
|
|
3467
3536
|
if (!definitions || definitions.length === 0) {
|
|
3468
3537
|
return { found: false, function: name };
|
|
@@ -3593,6 +3662,7 @@ class ProjectIndex {
|
|
|
3593
3662
|
filesAffected: new Set(changes.map(c => c.file)).size,
|
|
3594
3663
|
changes
|
|
3595
3664
|
};
|
|
3665
|
+
} finally { this._endOp(); }
|
|
3596
3666
|
}
|
|
3597
3667
|
|
|
3598
3668
|
/**
|
|
@@ -3816,7 +3886,7 @@ class ProjectIndex {
|
|
|
3816
3886
|
}
|
|
3817
3887
|
|
|
3818
3888
|
try {
|
|
3819
|
-
const content =
|
|
3889
|
+
const content = this._readFile(resolvedPath);
|
|
3820
3890
|
const lines = content.split('\n');
|
|
3821
3891
|
|
|
3822
3892
|
// Get the exact line
|
|
@@ -3893,6 +3963,8 @@ class ProjectIndex {
|
|
|
3893
3963
|
* @returns {object} Verification results with mismatches
|
|
3894
3964
|
*/
|
|
3895
3965
|
verify(name, options = {}) {
|
|
3966
|
+
this._beginOp();
|
|
3967
|
+
try {
|
|
3896
3968
|
const { def } = this.resolveSymbol(name, { file: options.file });
|
|
3897
3969
|
if (!def) {
|
|
3898
3970
|
return { found: false, function: name };
|
|
@@ -4011,6 +4083,7 @@ class ProjectIndex {
|
|
|
4011
4083
|
mismatchDetails: mismatches,
|
|
4012
4084
|
uncertainDetails: uncertain
|
|
4013
4085
|
};
|
|
4086
|
+
} finally { this._endOp(); }
|
|
4014
4087
|
}
|
|
4015
4088
|
|
|
4016
4089
|
/**
|
|
@@ -4030,7 +4103,7 @@ class ProjectIndex {
|
|
|
4030
4103
|
// Use tree cache to avoid re-parsing the same file in batch operations
|
|
4031
4104
|
let tree = this._treeCache?.get(call.file);
|
|
4032
4105
|
if (!tree) {
|
|
4033
|
-
const content =
|
|
4106
|
+
const content = this._readFile(call.file);
|
|
4034
4107
|
tree = safeParse(parser, content);
|
|
4035
4108
|
if (!tree) return { args: null, argCount: 0 };
|
|
4036
4109
|
if (!this._treeCache) this._treeCache = new Map();
|
|
@@ -4165,6 +4238,8 @@ class ProjectIndex {
|
|
|
4165
4238
|
* @returns {object} Complete symbol info
|
|
4166
4239
|
*/
|
|
4167
4240
|
about(name, options = {}) {
|
|
4241
|
+
this._beginOp();
|
|
4242
|
+
try {
|
|
4168
4243
|
const maxCallers = options.all ? Infinity : (options.maxCallers || 10);
|
|
4169
4244
|
const maxCallees = options.all ? Infinity : (options.maxCallees || 10);
|
|
4170
4245
|
const includeMethods = options.includeMethods ?? true;
|
|
@@ -4315,6 +4390,7 @@ class ProjectIndex {
|
|
|
4315
4390
|
};
|
|
4316
4391
|
|
|
4317
4392
|
return result;
|
|
4393
|
+
} finally { this._endOp(); }
|
|
4318
4394
|
}
|
|
4319
4395
|
|
|
4320
4396
|
/**
|
|
@@ -4323,6 +4399,8 @@ class ProjectIndex {
|
|
|
4323
4399
|
* @param {object} options - { codeOnly, context }
|
|
4324
4400
|
*/
|
|
4325
4401
|
search(term, options = {}) {
|
|
4402
|
+
this._beginOp();
|
|
4403
|
+
try {
|
|
4326
4404
|
const results = [];
|
|
4327
4405
|
// Escape the term to handle special regex characters
|
|
4328
4406
|
const regexFlags = options.caseSensitive ? 'g' : 'gi';
|
|
@@ -4330,7 +4408,7 @@ class ProjectIndex {
|
|
|
4330
4408
|
|
|
4331
4409
|
for (const [filePath, fileEntry] of this.files) {
|
|
4332
4410
|
try {
|
|
4333
|
-
const content =
|
|
4411
|
+
const content = this._readFile(filePath);
|
|
4334
4412
|
const lines = content.split('\n');
|
|
4335
4413
|
const matches = [];
|
|
4336
4414
|
|
|
@@ -4422,6 +4500,7 @@ class ProjectIndex {
|
|
|
4422
4500
|
}
|
|
4423
4501
|
|
|
4424
4502
|
return results;
|
|
4503
|
+
} finally { this._endOp(); }
|
|
4425
4504
|
}
|
|
4426
4505
|
|
|
4427
4506
|
// ========================================================================
|
|
@@ -4774,6 +4853,8 @@ class ProjectIndex {
|
|
|
4774
4853
|
* @returns {{ best: object, totalCalls: number } | null}
|
|
4775
4854
|
*/
|
|
4776
4855
|
example(name) {
|
|
4856
|
+
this._beginOp();
|
|
4857
|
+
try {
|
|
4777
4858
|
const usages = this.usages(name, {
|
|
4778
4859
|
codeOnly: true,
|
|
4779
4860
|
exclude: ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'],
|
|
@@ -4823,6 +4904,7 @@ class ProjectIndex {
|
|
|
4823
4904
|
|
|
4824
4905
|
scored.sort((a, b) => b.score - a.score);
|
|
4825
4906
|
return { best: scored[0], totalCalls: calls.length };
|
|
4907
|
+
} finally { this._endOp(); }
|
|
4826
4908
|
}
|
|
4827
4909
|
|
|
4828
4910
|
/**
|
|
@@ -4841,7 +4923,7 @@ class ProjectIndex {
|
|
|
4841
4923
|
if (!language) return result;
|
|
4842
4924
|
|
|
4843
4925
|
const parser = getParser(language);
|
|
4844
|
-
const content =
|
|
4926
|
+
const content = this._readFile(filePath);
|
|
4845
4927
|
const tree = parser.parse(content, undefined, PARSE_OPTIONS);
|
|
4846
4928
|
|
|
4847
4929
|
const row = lineNum - 1;
|
|
@@ -4900,6 +4982,8 @@ class ProjectIndex {
|
|
|
4900
4982
|
* @returns {object} - { base, functions, moduleLevelChanges, newFunctions, deletedFunctions, summary }
|
|
4901
4983
|
*/
|
|
4902
4984
|
diffImpact(options = {}) {
|
|
4985
|
+
this._beginOp();
|
|
4986
|
+
try {
|
|
4903
4987
|
const { base = 'HEAD', staged = false, file } = options;
|
|
4904
4988
|
|
|
4905
4989
|
// Verify git repo
|
|
@@ -5018,8 +5102,7 @@ class ProjectIndex {
|
|
|
5018
5102
|
// We approximate: if a deleted line is within the range of a known symbol, it's a modification.
|
|
5019
5103
|
let matched = false;
|
|
5020
5104
|
for (const symbol of fileEntry.symbols) {
|
|
5021
|
-
|
|
5022
|
-
if (nonCallableTypes.has(symbol.type)) continue;
|
|
5105
|
+
if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
|
|
5023
5106
|
// Use a generous range — deleted lines near a function likely belong to it
|
|
5024
5107
|
if (line >= symbol.startLine - 2 && line <= symbol.endLine + 2) {
|
|
5025
5108
|
const key = `${symbol.name}:${symbol.startLine}`;
|
|
@@ -5076,11 +5159,10 @@ class ProjectIndex {
|
|
|
5076
5159
|
const fileLang = detectLanguage(change.filePath);
|
|
5077
5160
|
if (fileLang) {
|
|
5078
5161
|
const oldParsed = parse(oldContent, fileLang);
|
|
5079
|
-
const nonCallableTypes = new Set(['class', 'struct', 'interface', 'type', 'state', 'impl', 'enum', 'trait']);
|
|
5080
5162
|
// Count current symbols by identity (name + className)
|
|
5081
5163
|
const currentCounts = new Map();
|
|
5082
5164
|
for (const s of fileEntry.symbols) {
|
|
5083
|
-
if (
|
|
5165
|
+
if (NON_CALLABLE_TYPES.has(s.type)) continue;
|
|
5084
5166
|
const key = `${s.name}\0${s.className || ''}`;
|
|
5085
5167
|
currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
|
|
5086
5168
|
}
|
|
@@ -5164,6 +5246,7 @@ class ProjectIndex {
|
|
|
5164
5246
|
affectedFiles: callerFileSet.size
|
|
5165
5247
|
}
|
|
5166
5248
|
};
|
|
5249
|
+
} finally { this._endOp(); }
|
|
5167
5250
|
}
|
|
5168
5251
|
}
|
|
5169
5252
|
|
package/core/shared.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/shared.js - Shared utility functions used by both CLI and MCP server
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { isTestFile } = require('./discovery');
|
|
6
|
+
const { detectLanguage } = require('./parser');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pick the best definition from multiple matches.
|
|
10
|
+
* Prefers non-test, src/lib files, larger function bodies.
|
|
11
|
+
*/
|
|
12
|
+
function pickBestDefinition(matches) {
|
|
13
|
+
const typeOrder = new Set(['class', 'struct', 'interface', 'type', 'impl']);
|
|
14
|
+
const scored = matches.map(m => {
|
|
15
|
+
let score = 0;
|
|
16
|
+
const rp = m.relativePath || '';
|
|
17
|
+
// Prefer class/struct/interface types (+1000) - same as resolveSymbol
|
|
18
|
+
if (typeOrder.has(m.type)) score += 1000;
|
|
19
|
+
if (isTestFile(rp, detectLanguage(m.file))) score -= 500;
|
|
20
|
+
if (/^(examples?|docs?|vendor|third[_-]?party|benchmarks?|samples?)\//i.test(rp)) score -= 300;
|
|
21
|
+
if (/^(lib|src|core|internal|pkg|crates)\//i.test(rp)) score += 200;
|
|
22
|
+
// Tiebreaker: prefer larger function bodies (more important/complex)
|
|
23
|
+
if (m.startLine && m.endLine) {
|
|
24
|
+
score += Math.min(m.endLine - m.startLine, 100);
|
|
25
|
+
}
|
|
26
|
+
return { match: m, score };
|
|
27
|
+
});
|
|
28
|
+
scored.sort((a, b) => b.score - a.score);
|
|
29
|
+
return scored[0].match;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Add standard test exclusion patterns to an exclude array.
|
|
34
|
+
* Returns a new array with test patterns appended (deduplicating).
|
|
35
|
+
*/
|
|
36
|
+
function addTestExclusions(exclude) {
|
|
37
|
+
const testPatterns = ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'];
|
|
38
|
+
const existing = new Set((exclude || []).map(e => e.toLowerCase()));
|
|
39
|
+
const additions = testPatterns.filter(p => !existing.has(p));
|
|
40
|
+
return [...(exclude || []), ...additions];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { pickBestDefinition, addTestExclusions };
|
package/languages/go.js
CHANGED
|
@@ -183,12 +183,16 @@ function findClasses(code, parser) {
|
|
|
183
183
|
// Check if exported
|
|
184
184
|
const isExported = /^[A-Z]/.test(name);
|
|
185
185
|
|
|
186
|
+
const members = typeKind === 'struct' ? extractStructFields(typeNode, code)
|
|
187
|
+
: typeKind === 'interface' ? extractInterfaceMembers(typeNode, code)
|
|
188
|
+
: [];
|
|
189
|
+
|
|
186
190
|
types.push({
|
|
187
191
|
name,
|
|
188
192
|
startLine,
|
|
189
193
|
endLine,
|
|
190
194
|
type: typeKind,
|
|
191
|
-
members
|
|
195
|
+
members,
|
|
192
196
|
modifiers: isExported ? ['export'] : [],
|
|
193
197
|
...(docstring && { docstring }),
|
|
194
198
|
...(typeParams && { generics: typeParams })
|
|
@@ -235,6 +239,62 @@ function extractStructFields(structNode, code) {
|
|
|
235
239
|
return fields;
|
|
236
240
|
}
|
|
237
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Extract interface method signatures
|
|
244
|
+
*/
|
|
245
|
+
function extractInterfaceMembers(interfaceNode, code) {
|
|
246
|
+
const members = [];
|
|
247
|
+
for (let i = 0; i < interfaceNode.namedChildCount; i++) {
|
|
248
|
+
const child = interfaceNode.namedChild(i);
|
|
249
|
+
// tree-sitter Go uses method_elem (or method_spec in older versions)
|
|
250
|
+
if (child.type === 'method_elem' || child.type === 'method_spec') {
|
|
251
|
+
const { startLine, endLine } = nodeToLocation(child, code);
|
|
252
|
+
// Name is in a field_identifier child
|
|
253
|
+
let nameText = null;
|
|
254
|
+
let paramsText = null;
|
|
255
|
+
let returnType = null;
|
|
256
|
+
for (let j = 0; j < child.namedChildCount; j++) {
|
|
257
|
+
const sub = child.namedChild(j);
|
|
258
|
+
if (sub.type === 'field_identifier' || sub.type === 'type_identifier') {
|
|
259
|
+
if (!nameText) nameText = sub.text;
|
|
260
|
+
} else if (sub.type === 'parameter_list') {
|
|
261
|
+
if (!paramsText) {
|
|
262
|
+
paramsText = sub.text.slice(1, -1); // strip parens
|
|
263
|
+
} else {
|
|
264
|
+
// Second parameter_list is the return type tuple
|
|
265
|
+
returnType = sub.text;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Also check childForFieldName for compatibility
|
|
270
|
+
if (!nameText) {
|
|
271
|
+
const nameNode = child.childForFieldName('name');
|
|
272
|
+
if (nameNode) nameText = nameNode.text;
|
|
273
|
+
}
|
|
274
|
+
if (!returnType) {
|
|
275
|
+
// Single return type (not a tuple) is a type_identifier
|
|
276
|
+
for (let j = 0; j < child.namedChildCount; j++) {
|
|
277
|
+
const sub = child.namedChild(j);
|
|
278
|
+
if (sub.type === 'type_identifier' && sub.text !== nameText) {
|
|
279
|
+
returnType = sub.text;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (nameText) {
|
|
284
|
+
members.push({
|
|
285
|
+
name: nameText,
|
|
286
|
+
startLine,
|
|
287
|
+
endLine,
|
|
288
|
+
memberType: 'method',
|
|
289
|
+
...(paramsText !== null && { params: paramsText }),
|
|
290
|
+
...(returnType && { returnType })
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return members;
|
|
296
|
+
}
|
|
297
|
+
|
|
238
298
|
/**
|
|
239
299
|
* Find state objects (constants) in Go code
|
|
240
300
|
*/
|