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/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 escaped = pattern.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
511
- const regex = new RegExp(`(^|[/._\\-])${escaped}s?([/._\\-]|$)`);
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 = fs.readFileSync(filePath, 'utf-8');
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 = fs.readFileSync(filePath, 'utf-8');
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 || fs.readFileSync(filePath, 'utf-8');
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 = fs.readFileSync(filePath, 'utf-8');
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 => !nonCallableTypes.has(s.type) &&
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 = fs.readFileSync(filePath, 'utf-8');
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 = fs.readFileSync(symbol.file, 'utf-8');
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 (!nonCallableTypes.has(symbol.type) &&
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 = fs.readFileSync(filePath, 'utf-8');
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 = fs.readFileSync(normalizedPath, 'utf-8');
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 = fs.readFileSync(importerPath, 'utf-8');
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 = fs.readFileSync(testPath, 'utf-8');
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 = fs.readFileSync(filePath, 'utf-8');
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 = fs.readFileSync(filePath, 'utf-8');
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 = fs.readFileSync(filePath, 'utf-8');
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 = new Set(this.findCallees(def).map(c => c.name));
3225
- if (myCallees.size > 0) {
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 [symName, symbols] of this.symbols) {
3228
- if (symName === name) continue;
3229
- const sym = symbols[0];
3230
- if (sym.type !== 'function' && sym.params === undefined) continue;
3231
-
3232
- const theirCallees = this.findCallees(sym);
3233
- const shared = theirCallees.filter(c => myCallees.has(c.name));
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 = fs.readFileSync(resolvedPath, 'utf-8');
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 = fs.readFileSync(call.file, 'utf-8');
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 = fs.readFileSync(filePath, 'utf-8');
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 = fs.readFileSync(filePath, 'utf-8');
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
- const nonCallableTypes = new Set(['class', 'struct', 'interface', 'type', 'state', 'impl', 'enum', 'trait']);
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 (nonCallableTypes.has(s.type)) continue;
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: typeKind === 'struct' ? extractStructFields(typeNode, code) : [],
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
  */