ucn 3.1.8 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ucn might be problematic. Click here for more details.

package/core/project.js CHANGED
@@ -11,12 +11,15 @@ const crypto = require('crypto');
11
11
  const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile } = require('./discovery');
12
12
  const { extractImports, extractExports, resolveImport } = require('./imports');
13
13
  const { parseFile } = require('./parser');
14
- const { detectLanguage, getParser, getLanguageModule, PARSE_OPTIONS } = require('../languages');
14
+ const { detectLanguage, getParser, getLanguageModule, PARSE_OPTIONS, safeParse } = require('../languages');
15
15
  const { getTokenTypeAtPosition } = require('../languages/utils');
16
16
 
17
17
  // Read UCN version for cache invalidation
18
18
  const UCN_VERSION = require('../package.json').version;
19
19
 
20
+ // Lazy-initialized per-language keyword sets (populated on first isKeyword call)
21
+ let LANGUAGE_KEYWORDS = null;
22
+
20
23
  /**
21
24
  * Escape special regex characters
22
25
  */
@@ -136,7 +139,7 @@ class ProjectIndex {
136
139
  if (!language) return;
137
140
 
138
141
  const parsed = parseFile(filePath);
139
- const { imports } = extractImports(content, language);
142
+ const { imports, dynamicCount } = extractImports(content, language);
140
143
  const { exports } = extractExports(content, language);
141
144
 
142
145
  const fileEntry = {
@@ -149,8 +152,10 @@ class ProjectIndex {
149
152
  size: stat.size,
150
153
  imports: imports.map(i => i.module),
151
154
  exports: exports.map(e => e.name),
152
- symbols: []
155
+ symbols: [],
156
+ bindings: []
153
157
  };
158
+ fileEntry.dynamicImports = dynamicCount || 0;
154
159
 
155
160
  // Add symbols
156
161
  const addSymbol = (item, type) => {
@@ -166,6 +171,7 @@ class ProjectIndex {
166
171
  returnType: item.returnType,
167
172
  modifiers: item.modifiers,
168
173
  docstring: item.docstring,
174
+ bindingId: `${fileEntry.relativePath}:${type}:${item.startLine}`,
169
175
  ...(item.extends && { extends: item.extends }),
170
176
  ...(item.implements && { implements: item.implements }),
171
177
  ...(item.indent !== undefined && { indent: item.indent }),
@@ -176,6 +182,12 @@ class ProjectIndex {
176
182
  ...(item.memberType && { memberType: item.memberType })
177
183
  };
178
184
  fileEntry.symbols.push(symbol);
185
+ fileEntry.bindings.push({
186
+ id: symbol.bindingId,
187
+ name: symbol.name,
188
+ type: symbol.type,
189
+ startLine: symbol.startLine
190
+ });
179
191
 
180
192
  if (!this.symbols.has(item.name)) {
181
193
  this.symbols.set(item.name, []);
@@ -231,16 +243,41 @@ class ProjectIndex {
231
243
  this.importGraph.clear();
232
244
  this.exportGraph.clear();
233
245
 
246
+ // Build Java suffix lookup for package import resolution
247
+ // Maps "com/google/gson/Gson.java" -> absolute path
248
+ let javaSuffixMap = null;
249
+
234
250
  for (const [filePath, fileEntry] of this.files) {
235
251
  const importedFiles = [];
236
252
 
237
253
  for (const importModule of fileEntry.imports) {
238
- const resolved = resolveImport(importModule, filePath, {
254
+ let resolved = resolveImport(importModule, filePath, {
239
255
  aliases: this.config.aliases,
240
256
  language: fileEntry.language,
241
257
  root: this.root
242
258
  });
243
259
 
260
+ // Java package imports: resolve by matching file path suffix
261
+ // e.g., "com.google.gson.Gson" -> find file ending in "com/google/gson/Gson.java"
262
+ if (!resolved && fileEntry.language === 'java' &&
263
+ !importModule.startsWith('.') && !importModule.endsWith('.*')) {
264
+ if (!javaSuffixMap) {
265
+ javaSuffixMap = new Map();
266
+ for (const [absPath, entry] of this.files) {
267
+ if (entry.language === 'java') {
268
+ javaSuffixMap.set(absPath, absPath);
269
+ }
270
+ }
271
+ }
272
+ const fileSuffix = '/' + importModule.split('.').join('/') + '.java';
273
+ for (const absPath of javaSuffixMap.keys()) {
274
+ if (absPath.endsWith(fileSuffix)) {
275
+ resolved = absPath;
276
+ break;
277
+ }
278
+ }
279
+ }
280
+
244
281
  if (resolved && this.files.has(resolved)) {
245
282
  importedFiles.push(resolved);
246
283
 
@@ -284,6 +321,17 @@ class ProjectIndex {
284
321
  }
285
322
  }
286
323
 
324
+ /**
325
+ * Count dynamic imports across indexed files
326
+ */
327
+ getDynamicImportCount() {
328
+ let total = 0;
329
+ for (const fileEntry of this.files.values()) {
330
+ total += fileEntry.dynamicImports || 0;
331
+ }
332
+ return total;
333
+ }
334
+
287
335
  // ========================================================================
288
336
  // QUERY METHODS
289
337
  // ========================================================================
@@ -364,6 +412,80 @@ class ProjectIndex {
364
412
  * @param {object} options - { file, prefer, exact, exclude, in }
365
413
  * @returns {Array} Matching symbols with usage counts
366
414
  */
415
+
416
+ /**
417
+ * Resolve a symbol name to the best matching definition.
418
+ * Centralized selection logic used by all commands for consistency.
419
+ *
420
+ * Priority order:
421
+ * 1. Filter by --file if specified
422
+ * 2. Prefer class/struct/interface/type over functions/constructors
423
+ * 3. Prefer non-test file definitions over test files
424
+ * 4. Prefer higher usage count
425
+ *
426
+ * @param {string} name - Symbol name
427
+ * @param {object} [options] - { file }
428
+ * @returns {{ def: object|null, definitions: Array, warnings: Array }}
429
+ */
430
+ resolveSymbol(name, options = {}) {
431
+ let definitions = this.symbols.get(name) || [];
432
+ if (definitions.length === 0) {
433
+ return { def: null, definitions: [], warnings: [] };
434
+ }
435
+
436
+ // Filter by file if specified
437
+ if (options.file) {
438
+ const filtered = definitions.filter(d =>
439
+ d.relativePath && d.relativePath.includes(options.file)
440
+ );
441
+ if (filtered.length > 0) {
442
+ definitions = filtered;
443
+ }
444
+ }
445
+
446
+ // Score each definition for selection
447
+ const typeOrder = new Set(['class', 'struct', 'interface', 'type', 'impl']);
448
+ const scored = definitions.map(d => {
449
+ let score = 0;
450
+ const rp = d.relativePath || '';
451
+ // Prefer class/struct/interface types (+1000)
452
+ if (typeOrder.has(d.type)) score += 1000;
453
+ // Deprioritize test files (-500)
454
+ if (isTestFile(rp, detectLanguage(d.file))) {
455
+ score -= 500;
456
+ }
457
+ // Deprioritize examples/docs/vendor directories (-300)
458
+ if (/^(examples?|docs?|vendor|third[_-]?party|benchmarks?|samples?)\//i.test(rp)) {
459
+ score -= 300;
460
+ }
461
+ // Boost lib/src/core/internal directories (+200)
462
+ if (/^(lib|src|core|internal|pkg|crates)\//i.test(rp)) {
463
+ score += 200;
464
+ }
465
+ return { def: d, score };
466
+ });
467
+
468
+ // Sort by score descending, then by index order for stability
469
+ scored.sort((a, b) => b.score - a.score);
470
+
471
+ const def = scored[0].def;
472
+
473
+ // Build warnings
474
+ const warnings = [];
475
+ if (definitions.length > 1) {
476
+ warnings.push({
477
+ type: 'ambiguous',
478
+ message: `Found ${definitions.length} definitions for "${name}". Using ${def.relativePath}:${def.startLine}. Use --file to disambiguate.`,
479
+ alternatives: definitions.filter(d => d !== def).map(d => ({
480
+ file: d.relativePath,
481
+ line: d.startLine
482
+ }))
483
+ });
484
+ }
485
+
486
+ return { def, definitions, warnings };
487
+ }
488
+
367
489
  find(name, options = {}) {
368
490
  const matches = this.symbols.get(name) || [];
369
491
 
@@ -416,25 +538,6 @@ class ProjectIndex {
416
538
  return withCounts;
417
539
  }
418
540
 
419
- /**
420
- * Count usages of a symbol across the codebase
421
- */
422
- countUsages(name) {
423
- let count = 0;
424
- const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b', 'g');
425
-
426
- for (const [filePath, fileEntry] of this.files) {
427
- try {
428
- const content = fs.readFileSync(filePath, 'utf-8');
429
- const matches = content.match(regex);
430
- if (matches) count += matches.length;
431
- } catch (e) {
432
- // Skip unreadable files
433
- }
434
- }
435
-
436
- return count;
437
- }
438
541
 
439
542
  /**
440
543
  * Count usages of a specific symbol (not just by name)
@@ -709,30 +812,10 @@ class ProjectIndex {
709
812
  * Get context for a symbol (callers + callees)
710
813
  */
711
814
  context(name, options = {}) {
712
- let definitions = this.symbols.get(name) || [];
713
- if (definitions.length === 0) {
714
- return { function: name, file: null, callers: [], callees: [] };
715
- }
716
-
717
- // Filter by file if specified
718
- if (options.file) {
719
- const filtered = definitions.filter(d =>
720
- d.relativePath && d.relativePath.includes(options.file)
721
- );
722
- if (filtered.length > 0) {
723
- definitions = filtered;
724
- }
725
- }
726
-
727
- // Prefer class/struct/interface definitions over functions/methods/constructors
728
- // This ensures context('ClassName') finds the class, not a constructor with same name
729
- const typeOrder = ['class', 'struct', 'interface', 'type', 'impl'];
730
- let def = definitions[0];
731
- for (const d of definitions) {
732
- if (typeOrder.includes(d.type)) {
733
- def = d;
734
- break;
735
- }
815
+ const resolved = this.resolveSymbol(name, { file: options.file });
816
+ let { def, definitions, warnings } = resolved;
817
+ if (!def) {
818
+ return null;
736
819
  }
737
820
 
738
821
  // Special handling for class/struct/interface types
@@ -754,25 +837,28 @@ class ProjectIndex {
754
837
  receiver: m.receiver
755
838
  })),
756
839
  // Also include places where the type is used in function parameters/returns
757
- callers: this.findCallers(name, { includeMethods: options.includeMethods })
840
+ callers: this.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain })
758
841
  };
759
842
 
760
- if (definitions.length > 1) {
761
- result.warnings = [{
762
- type: 'ambiguous',
763
- message: `Found ${definitions.length} definitions for "${name}". Using ${def.relativePath}:${def.startLine}. Use --file to disambiguate.`,
764
- alternatives: definitions.slice(1).map(d => ({
765
- file: d.relativePath,
766
- line: d.startLine
767
- }))
768
- }];
843
+ if (warnings.length > 0) {
844
+ result.warnings = warnings;
769
845
  }
770
846
 
771
847
  return result;
772
848
  }
773
849
 
774
- const callers = this.findCallers(name, { includeMethods: options.includeMethods });
775
- const callees = this.findCallees(def, { includeMethods: options.includeMethods });
850
+ const stats = { uncertain: 0 };
851
+ const callers = this.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
852
+ const callees = this.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
853
+
854
+ const filesInScope = new Set([def.file]);
855
+ callers.forEach(c => filesInScope.add(c.file));
856
+ callees.forEach(c => filesInScope.add(c.file));
857
+ let dynamicImports = 0;
858
+ for (const f of filesInScope) {
859
+ const fe = this.files.get(f);
860
+ if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
861
+ }
776
862
 
777
863
  const result = {
778
864
  function: name,
@@ -782,19 +868,17 @@ class ProjectIndex {
782
868
  params: def.params,
783
869
  returnType: def.returnType,
784
870
  callers,
785
- callees
871
+ callees,
872
+ meta: {
873
+ complete: stats.uncertain === 0 && dynamicImports === 0,
874
+ skipped: 0,
875
+ dynamicImports,
876
+ uncertain: stats.uncertain
877
+ }
786
878
  };
787
879
 
788
- // Add disambiguation warning if multiple definitions exist
789
- if (definitions.length > 1) {
790
- result.warnings = [{
791
- type: 'ambiguous',
792
- message: `Found ${definitions.length} definitions for "${name}". Using ${def.relativePath}:${def.startLine}. Use --file to disambiguate.`,
793
- alternatives: definitions.slice(1).map(d => ({
794
- file: d.relativePath,
795
- line: d.startLine
796
- }))
797
- }];
880
+ if (warnings.length > 0) {
881
+ result.warnings = warnings;
798
882
  }
799
883
 
800
884
  return result;
@@ -875,6 +959,7 @@ class ProjectIndex {
875
959
  */
876
960
  findCallers(name, options = {}) {
877
961
  const callers = [];
962
+ const stats = options.stats;
878
963
 
879
964
  // Get definition lines to exclude them
880
965
  const definitions = this.symbols.get(name) || [];
@@ -895,6 +980,23 @@ class ProjectIndex {
895
980
  // Skip if not matching our target name
896
981
  if (call.name !== name) continue;
897
982
 
983
+ // Resolve binding within this file (without mutating cached call objects)
984
+ let bindingId = call.bindingId;
985
+ let isUncertain = call.uncertain;
986
+ if (!bindingId) {
987
+ const bindings = (fileEntry.bindings || []).filter(b => b.name === call.name);
988
+ if (bindings.length === 1) {
989
+ bindingId = bindings[0].id;
990
+ } else if (bindings.length !== 0) {
991
+ isUncertain = true;
992
+ }
993
+ }
994
+
995
+ if (isUncertain && !options.includeUncertain) {
996
+ if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
997
+ continue;
998
+ }
999
+
898
1000
  // Smart method call handling
899
1001
  if (call.isMethod) {
900
1002
  // Always skip this/self/cls calls (internal state access, not function calls)
@@ -907,6 +1009,12 @@ class ProjectIndex {
907
1009
  // Skip definition lines
908
1010
  if (definitionLines.has(`${filePath}:${call.line}`)) continue;
909
1011
 
1012
+ // If we have a binding id on definition, require match when available
1013
+ const targetBindingIds = new Set(definitions.map(d => d.bindingId).filter(Boolean));
1014
+ if (targetBindingIds.size > 0 && bindingId && !targetBindingIds.has(bindingId)) {
1015
+ continue;
1016
+ }
1017
+
910
1018
  // Find the enclosing function (get full symbol info)
911
1019
  const callerSymbol = this.findEnclosingFunction(filePath, call.line, true);
912
1020
 
@@ -991,7 +1099,7 @@ class ProjectIndex {
991
1099
  const fileEntry = this.files.get(def.file);
992
1100
  const language = fileEntry?.language;
993
1101
 
994
- const callees = new Map(); // name -> count
1102
+ const callees = new Map(); // key -> { name, bindingId, count }
995
1103
 
996
1104
  for (const call of calls) {
997
1105
  // Filter to calls within this function's scope using enclosingFunction
@@ -1007,9 +1115,58 @@ class ProjectIndex {
1007
1115
  }
1008
1116
 
1009
1117
  // Skip keywords and built-ins
1010
- if (this.isKeyword(call.name)) continue;
1118
+ if (this.isKeyword(call.name, language)) continue;
1119
+
1120
+ // Resolve binding within this file (without mutating cached call objects)
1121
+ let calleeKey = call.bindingId || call.name;
1122
+ let bindingResolved = call.bindingId;
1123
+ let isUncertain = call.uncertain;
1124
+ if (!call.bindingId && fileEntry?.bindings) {
1125
+ const bindings = fileEntry.bindings.filter(b => b.name === call.name);
1126
+ if (bindings.length === 1) {
1127
+ bindingResolved = bindings[0].id;
1128
+ calleeKey = bindingResolved;
1129
+ } else if (bindings.length > 1) {
1130
+ if (call.name === def.name) {
1131
+ // Calling same-name function (e.g., Java overloads)
1132
+ // Add ALL other overloads as potential callees
1133
+ const otherBindings = bindings.filter(b =>
1134
+ b.startLine !== def.startLine
1135
+ );
1136
+ for (const ob of otherBindings) {
1137
+ const existing = callees.get(ob.id);
1138
+ if (existing) {
1139
+ existing.count += 1;
1140
+ } else {
1141
+ callees.set(ob.id, {
1142
+ name: call.name,
1143
+ bindingId: ob.id,
1144
+ count: 1
1145
+ });
1146
+ }
1147
+ }
1148
+ continue; // Already added all overloads, skip normal add
1149
+ } else {
1150
+ isUncertain = true;
1151
+ }
1152
+ }
1153
+ }
1154
+
1155
+ if (isUncertain && !options.includeUncertain) {
1156
+ if (options.stats) options.stats.uncertain = (options.stats.uncertain || 0) + 1;
1157
+ continue;
1158
+ }
1011
1159
 
1012
- callees.set(call.name, (callees.get(call.name) || 0) + 1);
1160
+ const existing = callees.get(calleeKey);
1161
+ if (existing) {
1162
+ existing.count += 1;
1163
+ } else {
1164
+ callees.set(calleeKey, {
1165
+ name: call.name,
1166
+ bindingId: bindingResolved,
1167
+ count: 1
1168
+ });
1169
+ }
1013
1170
  }
1014
1171
 
1015
1172
  // Look up each callee in the symbol table
@@ -1018,16 +1175,24 @@ class ProjectIndex {
1018
1175
  const defDir = path.dirname(def.file);
1019
1176
  const defReceiver = def.receiver;
1020
1177
 
1021
- for (const [calleeName, count] of callees) {
1178
+ for (const { name: calleeName, bindingId, count } of callees.values()) {
1022
1179
  const symbols = this.symbols.get(calleeName);
1023
1180
  if (symbols && symbols.length > 0) {
1024
1181
  let callee = symbols[0];
1025
1182
 
1026
- // If multiple definitions, try to find the best match
1027
- if (symbols.length > 1) {
1028
- // Priority 1: Same file
1183
+ // If we have a binding ID, find the exact matching symbol
1184
+ if (bindingId && symbols.length > 1) {
1185
+ const exactMatch = symbols.find(s => s.bindingId === bindingId);
1186
+ if (exactMatch) {
1187
+ callee = exactMatch;
1188
+ }
1189
+ } else if (symbols.length > 1) {
1190
+ // Priority 1: Same file, but different definition (for overloads)
1191
+ const sameFileDifferent = symbols.find(s => s.file === def.file && s.startLine !== def.startLine);
1029
1192
  const sameFile = symbols.find(s => s.file === def.file);
1030
- if (sameFile) {
1193
+ if (sameFileDifferent && calleeName === def.name) {
1194
+ callee = sameFileDifferent;
1195
+ } else if (sameFile) {
1031
1196
  callee = sameFile;
1032
1197
  } else {
1033
1198
  // Priority 2: Same directory (package)
@@ -1075,18 +1240,27 @@ class ProjectIndex {
1075
1240
  * Smart extraction: function + dependencies
1076
1241
  */
1077
1242
  smart(name, options = {}) {
1078
- const definitions = this.symbols.get(name) || [];
1079
- if (definitions.length === 0) {
1243
+ const { def } = this.resolveSymbol(name, { file: options.file });
1244
+ if (!def) {
1080
1245
  return null;
1081
1246
  }
1082
-
1083
- const def = definitions[0];
1084
1247
  const code = this.extractCode(def);
1085
- const callees = this.findCallees(def, { includeMethods: options.includeMethods });
1248
+ const stats = { uncertain: 0 };
1249
+ const callees = this.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
1086
1250
 
1087
- // Extract code for each dependency, excluding the main function itself
1251
+ const filesInScope = new Set([def.file]);
1252
+ callees.forEach(c => filesInScope.add(c.file));
1253
+ let dynamicImports = 0;
1254
+ for (const f of filesInScope) {
1255
+ const fe = this.files.get(f);
1256
+ if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
1257
+ }
1258
+
1259
+ // Extract code for each dependency, excluding the exact same function
1260
+ // (but keeping same-name overloads, e.g. Java toJson(Object) vs toJson(Object, Class))
1261
+ const defBindingId = def.bindingId;
1088
1262
  const dependencies = callees
1089
- .filter(callee => callee.name !== name) // Don't include self
1263
+ .filter(callee => callee.bindingId !== defBindingId)
1090
1264
  .map(callee => ({
1091
1265
  ...callee,
1092
1266
  code: this.extractCode(callee)
@@ -1118,7 +1292,13 @@ class ProjectIndex {
1118
1292
  code
1119
1293
  },
1120
1294
  dependencies,
1121
- types
1295
+ types,
1296
+ meta: {
1297
+ complete: stats.uncertain === 0 && dynamicImports === 0,
1298
+ skipped: 0,
1299
+ dynamicImports,
1300
+ uncertain: stats.uncertain
1301
+ }
1122
1302
  };
1123
1303
  }
1124
1304
 
@@ -1240,20 +1420,53 @@ class ProjectIndex {
1240
1420
  /**
1241
1421
  * Check if a name is a language keyword
1242
1422
  */
1243
- isKeyword(name) {
1244
- const keywords = new Set([
1245
- 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break',
1246
- 'continue', 'return', 'function', 'class', 'const', 'let', 'var',
1247
- 'new', 'this', 'super', 'import', 'export', 'default', 'from',
1248
- 'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield',
1249
- 'typeof', 'instanceof', 'in', 'of', 'delete', 'void', 'with',
1250
- 'def', 'print', 'range', 'len', 'str', 'int', 'float', 'list',
1251
- 'dict', 'set', 'tuple', 'True', 'False', 'None', 'self', 'cls',
1252
- 'func', 'type', 'struct', 'interface', 'package', 'make', 'append',
1253
- 'fn', 'impl', 'pub', 'mod', 'use', 'crate', 'self', 'super',
1254
- 'match', 'loop', 'unsafe', 'move', 'ref', 'mut', 'where'
1255
- ]);
1256
- return keywords.has(name);
1423
+ isKeyword(name, language) {
1424
+ if (!LANGUAGE_KEYWORDS) {
1425
+ // Initialize on first use
1426
+ LANGUAGE_KEYWORDS = {
1427
+ javascript: new Set([
1428
+ 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break',
1429
+ 'continue', 'return', 'function', 'class', 'const', 'let', 'var',
1430
+ 'new', 'this', 'super', 'import', 'export', 'default', 'from',
1431
+ 'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield',
1432
+ 'typeof', 'instanceof', 'in', 'of', 'delete', 'void', 'with'
1433
+ ]),
1434
+ python: new Set([
1435
+ 'if', 'else', 'elif', 'for', 'while', 'def', 'class', 'return',
1436
+ 'import', 'from', 'try', 'except', 'finally', 'raise', 'async',
1437
+ 'await', 'yield', 'with', 'as', 'lambda', 'pass', 'break',
1438
+ 'continue', 'del', 'global', 'nonlocal', 'assert', 'is', 'not',
1439
+ 'and', 'or', 'in', 'True', 'False', 'None', 'self', 'cls'
1440
+ ]),
1441
+ go: new Set([
1442
+ 'if', 'else', 'for', 'switch', 'case', 'break', 'continue',
1443
+ 'return', 'func', 'type', 'struct', 'interface', 'package',
1444
+ 'import', 'go', 'defer', 'select', 'chan', 'map', 'range',
1445
+ 'fallthrough', 'goto', 'var', 'const', 'default'
1446
+ ]),
1447
+ rust: new Set([
1448
+ 'if', 'else', 'for', 'while', 'loop', 'fn', 'impl', 'pub',
1449
+ 'mod', 'use', 'crate', 'self', 'super', 'match', 'unsafe',
1450
+ 'move', 'ref', 'mut', 'where', 'let', 'const', 'struct',
1451
+ 'enum', 'trait', 'async', 'await', 'return', 'break',
1452
+ 'continue', 'type', 'as', 'in', 'dyn', 'static'
1453
+ ]),
1454
+ java: new Set([
1455
+ 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break',
1456
+ 'continue', 'return', 'class', 'interface', 'enum', 'extends',
1457
+ 'implements', 'new', 'this', 'super', 'import', 'package',
1458
+ 'try', 'catch', 'finally', 'throw', 'throws', 'abstract',
1459
+ 'static', 'final', 'synchronized', 'volatile', 'transient',
1460
+ 'native', 'void', 'instanceof', 'default'
1461
+ ])
1462
+ };
1463
+ // TypeScript/TSX share JavaScript keywords
1464
+ LANGUAGE_KEYWORDS.typescript = LANGUAGE_KEYWORDS.javascript;
1465
+ LANGUAGE_KEYWORDS.tsx = LANGUAGE_KEYWORDS.javascript;
1466
+ }
1467
+
1468
+ const keywords = LANGUAGE_KEYWORDS[language];
1469
+ return keywords ? keywords.has(name) : false;
1257
1470
  }
1258
1471
 
1259
1472
  /**
@@ -2009,13 +2222,11 @@ class ProjectIndex {
2009
2222
  * @param {string} name - Function name
2010
2223
  * @returns {object} Related functions grouped by relationship type
2011
2224
  */
2012
- related(name) {
2013
- const definitions = this.symbols.get(name);
2014
- if (!definitions || definitions.length === 0) {
2225
+ related(name, options = {}) {
2226
+ const { def } = this.resolveSymbol(name, { file: options.file });
2227
+ if (!def) {
2015
2228
  return null;
2016
2229
  }
2017
-
2018
- const def = definitions[0];
2019
2230
  const related = {
2020
2231
  target: {
2021
2232
  name: def.name,
@@ -2149,22 +2360,10 @@ class ProjectIndex {
2149
2360
  const maxDepth = Math.max(0, rawDepth);
2150
2361
  const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
2151
2362
 
2152
- let definitions = this.symbols.get(name);
2153
- if (!definitions || definitions.length === 0) {
2363
+ const { def } = this.resolveSymbol(name, { file: options.file });
2364
+ if (!def) {
2154
2365
  return null;
2155
2366
  }
2156
-
2157
- // Filter by file if specified
2158
- if (options.file) {
2159
- const filtered = definitions.filter(d =>
2160
- d.relativePath && d.relativePath.includes(options.file)
2161
- );
2162
- if (filtered.length > 0) {
2163
- definitions = filtered;
2164
- }
2165
- }
2166
-
2167
- const def = definitions[0];
2168
2367
  const visited = new Set();
2169
2368
  const defDir = path.dirname(def.file);
2170
2369
 
@@ -2234,22 +2433,10 @@ class ProjectIndex {
2234
2433
  * @returns {object} Impact analysis
2235
2434
  */
2236
2435
  impact(name, options = {}) {
2237
- let definitions = this.symbols.get(name);
2238
- if (!definitions || definitions.length === 0) {
2436
+ const { def } = this.resolveSymbol(name, { file: options.file });
2437
+ if (!def) {
2239
2438
  return null;
2240
2439
  }
2241
-
2242
- // Filter by file if specified
2243
- if (options.file) {
2244
- const filtered = definitions.filter(d =>
2245
- d.relativePath && d.relativePath.includes(options.file)
2246
- );
2247
- if (filtered.length > 0) {
2248
- definitions = filtered;
2249
- }
2250
- }
2251
-
2252
- const def = definitions[0];
2253
2440
  const usages = this.usages(name, { codeOnly: true });
2254
2441
  const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
2255
2442
 
@@ -2264,6 +2451,7 @@ class ProjectIndex {
2264
2451
  ...analysis
2265
2452
  };
2266
2453
  });
2454
+ this._clearTreeCache();
2267
2455
 
2268
2456
  // Group by file if requested
2269
2457
  const byFile = new Map();
@@ -2729,13 +2917,11 @@ class ProjectIndex {
2729
2917
  * @param {string} name - Function name
2730
2918
  * @returns {object} Verification results with mismatches
2731
2919
  */
2732
- verify(name) {
2733
- const definitions = this.symbols.get(name);
2734
- if (!definitions || definitions.length === 0) {
2920
+ verify(name, options = {}) {
2921
+ const { def } = this.resolveSymbol(name, { file: options.file });
2922
+ if (!def) {
2735
2923
  return { found: false, function: name };
2736
2924
  }
2737
-
2738
- const def = definitions[0];
2739
2925
  const expectedParamCount = def.paramsStructured?.length || 0;
2740
2926
  const optionalCount = (def.paramsStructured || []).filter(p => p.optional || p.default !== undefined).length;
2741
2927
  const minArgs = expectedParamCount - optionalCount;
@@ -2749,9 +2935,18 @@ class ProjectIndex {
2749
2935
  const mismatches = [];
2750
2936
  const uncertain = [];
2751
2937
 
2938
+ // If the definition is NOT a method, filter out method calls (e.g., dict.get() vs get())
2939
+ // This prevents false positives where a standalone function name matches method calls
2940
+ const defIsMethod = def.isMethod || def.type === 'method' || def.className;
2941
+
2752
2942
  for (const call of calls) {
2753
2943
  const analysis = this.analyzeCallSite(call, name);
2754
2944
 
2945
+ // Skip method calls when verifying a non-method definition
2946
+ if (analysis.isMethodCall && !defIsMethod) {
2947
+ continue;
2948
+ }
2949
+
2755
2950
  if (analysis.args === null) {
2756
2951
  // Couldn't parse arguments
2757
2952
  uncertain.push({
@@ -2809,6 +3004,7 @@ class ProjectIndex {
2809
3004
  }
2810
3005
  }
2811
3006
  }
3007
+ this._clearTreeCache();
2812
3008
 
2813
3009
  return {
2814
3010
  found: true,
@@ -2822,7 +3018,7 @@ class ProjectIndex {
2822
3018
  hasDefault: p.default !== undefined
2823
3019
  })) || [],
2824
3020
  expectedArgs: { min: minArgs, max: hasRest ? '∞' : expectedParamCount },
2825
- totalCalls: calls.length,
3021
+ totalCalls: valid.length + mismatches.length + uncertain.length,
2826
3022
  valid: valid.length,
2827
3023
  mismatches: mismatches.length,
2828
3024
  uncertain: uncertain.length,
@@ -2832,87 +3028,105 @@ class ProjectIndex {
2832
3028
  }
2833
3029
 
2834
3030
  /**
2835
- * Analyze a call site to understand how it's being called
3031
+ * Analyze a call site to understand how it's being called (AST-based)
3032
+ * @param {object} call - Usage object with file, line, content
3033
+ * @param {string} funcName - Function name to find
3034
+ * @returns {object} { args, argCount, hasSpread, hasVariable }
2836
3035
  */
2837
3036
  analyzeCallSite(call, funcName) {
2838
- const content = call.content;
3037
+ try {
3038
+ const language = detectLanguage(call.file);
3039
+ if (!language) return { args: null, argCount: 0 };
2839
3040
 
2840
- // Extract arguments from the call
2841
- const callMatch = new RegExp('\\b' + escapeRegExp(funcName) + '\\s*\\(([^)]*)\\)').exec(content);
2842
- if (!callMatch) {
2843
- return { args: null, argCount: 0 };
2844
- }
3041
+ const parser = getParser(language);
3042
+ if (!parser) return { args: null, argCount: 0 };
3043
+
3044
+ // Use tree cache to avoid re-parsing the same file in batch operations
3045
+ let tree = this._treeCache?.get(call.file);
3046
+ if (!tree) {
3047
+ const content = fs.readFileSync(call.file, 'utf-8');
3048
+ tree = safeParse(parser, content);
3049
+ if (!tree) return { args: null, argCount: 0 };
3050
+ if (!this._treeCache) this._treeCache = new Map();
3051
+ this._treeCache.set(call.file, tree);
3052
+ }
3053
+
3054
+ // Call node types vary by language
3055
+ const callTypes = new Set(['call_expression', 'call', 'method_invocation']);
3056
+ const targetRow = call.line - 1; // tree-sitter is 0-indexed
3057
+
3058
+ // Find the call expression at the target line matching funcName
3059
+ const callNode = this._findCallNode(tree.rootNode, callTypes, targetRow, funcName);
3060
+ if (!callNode) return { args: null, argCount: 0 };
3061
+
3062
+ // Check if this is a method call (obj.func()) vs a direct call (func())
3063
+ const funcNode = callNode.childForFieldName('function') ||
3064
+ callNode.childForFieldName('name');
3065
+ let isMethodCall = false;
3066
+ if (funcNode) {
3067
+ // member_expression (JS), attribute (Python), selector_expression (Go), field_expression (Rust)
3068
+ if (['member_expression', 'attribute', 'selector_expression', 'field_expression'].includes(funcNode.type)) {
3069
+ isMethodCall = true;
3070
+ }
3071
+ // Java method_invocation with object
3072
+ if (callNode.type === 'method_invocation' && callNode.childForFieldName('object')) {
3073
+ isMethodCall = true;
3074
+ }
3075
+ }
2845
3076
 
2846
- const argsStr = callMatch[1].trim();
2847
- if (!argsStr) {
2848
- return { args: [], argCount: 0 };
2849
- }
3077
+ const argsNode = callNode.childForFieldName('arguments');
3078
+ if (!argsNode) return { args: [], argCount: 0, isMethodCall };
2850
3079
 
2851
- // Simple arg parsing (doesn't handle nested parens/strings perfectly but good enough)
2852
- const args = this.parseArguments(argsStr);
3080
+ const args = [];
3081
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
3082
+ args.push(argsNode.namedChild(i).text.trim());
3083
+ }
2853
3084
 
2854
- return {
2855
- args,
2856
- argCount: args.length,
2857
- hasSpread: args.some(a => a.startsWith('...')),
2858
- hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a))
2859
- };
3085
+ return {
3086
+ args,
3087
+ argCount: args.length,
3088
+ hasSpread: args.some(a => a.startsWith('...')),
3089
+ hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a)),
3090
+ isMethodCall
3091
+ };
3092
+ } catch (e) {
3093
+ return { args: null, argCount: 0 };
3094
+ }
2860
3095
  }
2861
3096
 
2862
3097
  /**
2863
- * Parse function call arguments (simple version)
3098
+ * Find a call expression node at the target line matching funcName
2864
3099
  */
2865
- parseArguments(argsStr) {
2866
- const args = [];
2867
- let current = '';
2868
- let depth = 0;
2869
- let inString = false;
2870
- let stringChar = '';
2871
-
2872
- for (let i = 0; i < argsStr.length; i++) {
2873
- const ch = argsStr[i];
2874
-
2875
- if (inString) {
2876
- current += ch;
2877
- if (ch === stringChar && argsStr[i - 1] !== '\\') {
2878
- inString = false;
2879
- }
2880
- continue;
2881
- }
2882
-
2883
- if (ch === '"' || ch === "'" || ch === '`') {
2884
- inString = true;
2885
- stringChar = ch;
2886
- current += ch;
2887
- continue;
2888
- }
2889
-
2890
- if (ch === '(' || ch === '[' || ch === '{') {
2891
- depth++;
2892
- current += ch;
2893
- continue;
2894
- }
2895
-
2896
- if (ch === ')' || ch === ']' || ch === '}') {
2897
- depth--;
2898
- current += ch;
2899
- continue;
2900
- }
3100
+ _findCallNode(node, callTypes, targetRow, funcName) {
3101
+ if (node.startPosition.row > targetRow || node.endPosition.row < targetRow) {
3102
+ return null; // Skip nodes that don't contain the target line
3103
+ }
2901
3104
 
2902
- if (ch === ',' && depth === 0) {
2903
- args.push(current.trim());
2904
- current = '';
2905
- continue;
3105
+ if (callTypes.has(node.type) && node.startPosition.row === targetRow) {
3106
+ // Check if this call is for our target function
3107
+ const funcNode = node.childForFieldName('function') ||
3108
+ node.childForFieldName('name'); // Java method_invocation uses 'name'
3109
+ if (funcNode) {
3110
+ const funcText = funcNode.type === 'member_expression' || funcNode.type === 'selector_expression' || funcNode.type === 'field_expression' || funcNode.type === 'attribute'
3111
+ ? (funcNode.childForFieldName('property') || funcNode.childForFieldName('field') || funcNode.childForFieldName('attribute') || funcNode.namedChild(funcNode.namedChildCount - 1))?.text
3112
+ : funcNode.text;
3113
+ if (funcText === funcName) return node;
2906
3114
  }
2907
-
2908
- current += ch;
2909
3115
  }
2910
3116
 
2911
- if (current.trim()) {
2912
- args.push(current.trim());
3117
+ // Recurse into children
3118
+ for (let i = 0; i < node.childCount; i++) {
3119
+ const result = this._findCallNode(node.child(i), callTypes, targetRow, funcName);
3120
+ if (result) return result;
2913
3121
  }
3122
+ return null;
3123
+ }
2914
3124
 
2915
- return args;
3125
+ /**
3126
+ * Clear the AST tree cache (call after batch operations)
3127
+ */
3128
+ _clearTreeCache() {
3129
+ this._treeCache = null;
2916
3130
  }
2917
3131
 
2918
3132
  /**
@@ -2979,9 +3193,12 @@ class ProjectIndex {
2979
3193
  };
2980
3194
  }
2981
3195
 
2982
- // Use the definition with highest usage count (primary implementation)
2983
- const primary = definitions[0];
2984
- const others = definitions.slice(1);
3196
+ // Use resolveSymbol for consistent primary selection (prefers non-test files)
3197
+ const { def: resolved } = this.resolveSymbol(name, { file: options.file });
3198
+ const primary = resolved || definitions[0];
3199
+ const others = definitions.filter(d =>
3200
+ d.relativePath !== primary.relativePath || d.startLine !== primary.startLine
3201
+ );
2985
3202
 
2986
3203
  // Use the actual symbol name (may differ from query if fuzzy matched)
2987
3204
  const symbolName = primary.name;
@@ -3247,42 +3464,89 @@ class ProjectIndex {
3247
3464
  /**
3248
3465
  * Get TOC for all files
3249
3466
  */
3250
- getToc() {
3467
+ getToc(options = {}) {
3251
3468
  const files = [];
3252
3469
  let totalFunctions = 0;
3253
3470
  let totalClasses = 0;
3254
3471
  let totalState = 0;
3255
3472
  let totalLines = 0;
3473
+ let totalDynamic = 0;
3474
+ let totalTests = 0;
3256
3475
 
3257
3476
  for (const [filePath, fileEntry] of this.files) {
3258
- const functions = fileEntry.symbols.filter(s => s.type === 'function');
3477
+ let functions = fileEntry.symbols.filter(s => s.type === 'function');
3259
3478
  const classes = fileEntry.symbols.filter(s =>
3260
3479
  ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl'].includes(s.type)
3261
3480
  );
3262
3481
  const state = fileEntry.symbols.filter(s => s.type === 'state');
3263
3482
 
3483
+ if (options.topLevel) {
3484
+ functions = functions.filter(fn => !fn.isNested && (!fn.indent || fn.indent === 0));
3485
+ }
3486
+
3264
3487
  totalFunctions += functions.length;
3265
3488
  totalClasses += classes.length;
3266
3489
  totalState += state.length;
3267
3490
  totalLines += fileEntry.lines;
3491
+ totalDynamic += fileEntry.dynamicImports || 0;
3492
+ if (isTestFile(filePath)) totalTests += 1;
3268
3493
 
3269
- files.push({
3494
+ const entry = {
3270
3495
  file: fileEntry.relativePath,
3271
3496
  language: fileEntry.language,
3272
3497
  lines: fileEntry.lines,
3273
- functions,
3274
- classes,
3275
- state
3276
- });
3498
+ functions: functions.length,
3499
+ classes: classes.length,
3500
+ state: state.length
3501
+ };
3502
+
3503
+ if (options.detailed) {
3504
+ entry.symbols = { functions, classes, state };
3505
+ }
3506
+
3507
+ files.push(entry);
3277
3508
  }
3278
3509
 
3510
+ // Hints: top files by function count and lines
3511
+ const topFunctionFiles = [...files]
3512
+ .sort((a, b) => b.functions - a.functions || b.lines - a.lines)
3513
+ .filter(f => f.functions > 0)
3514
+ .slice(0, 3)
3515
+ .map(f => ({ file: f.file, functions: f.functions }));
3516
+
3517
+ const topLineFiles = [...files]
3518
+ .sort((a, b) => b.lines - a.lines)
3519
+ .slice(0, 3)
3520
+ .map(f => ({ file: f.file, lines: f.lines }));
3521
+
3522
+ // Entry point candidates
3523
+ const entryPattern = /(main|index|server|app)\.(js|jsx|ts|tsx|py|go|rs|java)$/i;
3524
+ const entryFiles = files
3525
+ .filter(f => entryPattern.test(f.file))
3526
+ .slice(0, 5)
3527
+ .map(f => f.file);
3528
+
3279
3529
  return {
3280
- totalFiles: files.length,
3281
- totalLines,
3282
- totalFunctions,
3283
- totalClasses,
3284
- totalState,
3285
- byFile: files
3530
+ meta: {
3531
+ complete: totalDynamic === 0,
3532
+ skipped: 0,
3533
+ dynamicImports: totalDynamic,
3534
+ uncertain: 0
3535
+ },
3536
+ totals: {
3537
+ files: files.length,
3538
+ lines: totalLines,
3539
+ functions: totalFunctions,
3540
+ classes: totalClasses,
3541
+ state: totalState,
3542
+ testFiles: totalTests
3543
+ },
3544
+ summary: {
3545
+ topFunctionFiles,
3546
+ topLineFiles,
3547
+ entryFiles
3548
+ },
3549
+ files
3286
3550
  };
3287
3551
  }
3288
3552