ucn 3.7.24 → 3.7.26

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
@@ -14,23 +14,16 @@ const { extractImports, extractExports, resolveImport } = require('./imports');
14
14
  const { parse, parseFile, cleanHtmlScriptTags } = require('./parser');
15
15
  const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../languages');
16
16
  const { getTokenTypeAtPosition } = require('../languages/utils');
17
-
18
- // Read UCN version for cache invalidation
19
- const UCN_VERSION = require('../package.json').version;
17
+ const { escapeRegExp, NON_CALLABLE_TYPES } = require('./shared');
18
+ const stacktrace = require('./stacktrace');
19
+ const indexCache = require('./cache');
20
+ const deadcodeModule = require('./deadcode');
21
+ const verifyModule = require('./verify');
22
+ const callersModule = require('./callers');
20
23
 
21
24
  // Lazy-initialized per-language keyword sets (populated on first isKeyword call)
22
25
  let LANGUAGE_KEYWORDS = null;
23
26
 
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
-
27
- /**
28
- * Escape special regex characters
29
- */
30
- function escapeRegExp(text) {
31
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
- }
33
-
34
27
  /**
35
28
  * ProjectIndex - Manages symbol table for a project
36
29
  */
@@ -50,8 +43,10 @@ class ProjectIndex {
50
43
  this.config = this.loadConfig();
51
44
  this.buildTime = null;
52
45
  this.callsCache = new Map(); // filePath -> { mtime, hash, calls, content }
46
+ this.callsCacheDirty = false; // set by getCachedCalls when entries are added or mutated
53
47
  this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
54
48
  this._opContentCache = null; // per-operation file content cache (Map<filePath, string>)
49
+ this._opUsagesCache = null; // per-operation findUsagesInCode cache (Map<"file:name", usages[]>)
55
50
  }
56
51
 
57
52
  /**
@@ -70,16 +65,65 @@ class ProjectIndex {
70
65
  return fs.readFileSync(filePath, 'utf-8');
71
66
  }
72
67
 
73
- /** Start a per-operation content cache scope */
68
+ /** Start a per-operation content cache scope (supports nesting) */
74
69
  _beginOp() {
75
70
  if (!this._opContentCache) {
76
71
  this._opContentCache = new Map();
72
+ this._opUsagesCache = new Map();
73
+ this._opDepth = 0;
77
74
  }
75
+ this._opDepth++;
78
76
  }
79
77
 
80
- /** End a per-operation content cache scope */
78
+ /** End a per-operation content cache scope (only clears when outermost scope ends) */
81
79
  _endOp() {
82
- this._opContentCache = null;
80
+ if (--this._opDepth <= 0) {
81
+ this._opContentCache = null;
82
+ this._opUsagesCache = null;
83
+ this._opDepth = 0;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Get findUsagesInCode results with per-operation caching.
89
+ * Avoids redundant tree-sitter parsing when the same (file, name) is queried
90
+ * multiple times within one operation (e.g., about() calls both countSymbolUsages and usages).
91
+ * @param {string} filePath - File to scan
92
+ * @param {string} name - Symbol name to find
93
+ * @returns {Array|null} Array of usage objects or null if parsing failed
94
+ */
95
+ _getCachedUsages(filePath, name) {
96
+ const cacheKey = `${filePath}\0${name}`;
97
+ if (this._opUsagesCache) {
98
+ const cached = this._opUsagesCache.get(cacheKey);
99
+ if (cached !== undefined) return cached;
100
+ }
101
+
102
+ const lang = detectLanguage(filePath);
103
+ const langModule = getLanguageModule(lang);
104
+ if (!langModule || typeof langModule.findUsagesInCode !== 'function') return null;
105
+
106
+ try {
107
+ // Fast pre-check: skip tree-sitter parsing if name doesn't appear in file
108
+ const content = this._readFile(filePath);
109
+ if (!content.includes(name)) {
110
+ const empty = [];
111
+ if (this._opUsagesCache) {
112
+ this._opUsagesCache.set(cacheKey, empty);
113
+ }
114
+ return empty;
115
+ }
116
+
117
+ const parser = getParser(lang);
118
+ if (!parser) return null;
119
+ const usages = langModule.findUsagesInCode(content, name, parser);
120
+ if (this._opUsagesCache) {
121
+ this._opUsagesCache.set(cacheKey, usages);
122
+ }
123
+ return usages;
124
+ } catch (e) {
125
+ return null;
126
+ }
83
127
  }
84
128
 
85
129
  /**
@@ -144,8 +188,9 @@ class ProjectIndex {
144
188
  }
145
189
  }
146
190
 
147
- // Always invalidate completeness cache on rebuild
191
+ // Always invalidate caches on rebuild
148
192
  this._completenessCache = null;
193
+ this._attrTypeCache = null;
149
194
 
150
195
  let indexed = 0;
151
196
  if (!this.failedFiles) this.failedFiles = new Set();
@@ -172,6 +217,19 @@ class ProjectIndex {
172
217
  }
173
218
  }
174
219
 
220
+ /**
221
+ * Build a minimal index for a single file (no glob, no cache, no import graph).
222
+ * Used by CLI file mode to route through execute().
223
+ */
224
+ buildSingleFile(filePath) {
225
+ const absPath = path.resolve(filePath);
226
+ if (!fs.existsSync(absPath)) {
227
+ throw new Error(`File not found: ${filePath}`);
228
+ }
229
+ this.indexFile(absPath);
230
+ this.buildTime = 0;
231
+ }
232
+
175
233
  /**
176
234
  * Index a single file
177
235
  */
@@ -314,6 +372,9 @@ class ProjectIndex {
314
372
 
315
373
  // Invalidate cached call data for this file
316
374
  this.callsCache.delete(filePath);
375
+
376
+ // Invalidate attribute type cache for this file
377
+ if (this._attrTypeCache) this._attrTypeCache.delete(filePath);
317
378
  }
318
379
 
319
380
  /**
@@ -556,9 +617,12 @@ class ProjectIndex {
556
617
  }
557
618
  }
558
619
 
559
- // Check inclusion (must be within specified directory)
620
+ // Check inclusion (must be within specified directory, path-boundary-aware)
560
621
  if (filters.in) {
561
- if (!filePath.includes(filters.in)) {
622
+ const inPattern = filters.in;
623
+ // Match at path boundaries: start of string or after /
624
+ // e.g. --in=src matches "src/foo.js" and "lib/src/foo.js" but NOT "my-src-backup/foo.js"
625
+ if (!(filePath.startsWith(inPattern + '/') || filePath.includes('/' + inPattern + '/'))) {
562
626
  return false;
563
627
  }
564
628
  }
@@ -602,7 +666,7 @@ class ProjectIndex {
602
666
  if (lowerTarget.includes(lowerQuery)) return 400 + (query.length / target.length) * 100;
603
667
 
604
668
  // Word boundary match (parse -> parseFile, fileParse)
605
- const words = lowerTarget.split(/(?=[A-Z])|_|-/);
669
+ const words = target.split(/(?=[A-Z])|_|-/).map(w => w.toLowerCase());
606
670
  if (words.some(w => w.startsWith(lowerQuery))) return 300;
607
671
 
608
672
  return 0;
@@ -690,9 +754,16 @@ class ProjectIndex {
690
754
  }
691
755
 
692
756
  find(name, options = {}) {
757
+ this._beginOp();
758
+ try {
693
759
  // Glob pattern matching (e.g., _update*, handle*Request, get?ata)
694
760
  const isGlob = name.includes('*') || name.includes('?');
695
761
  if (isGlob && !options.exact) {
762
+ // Guard against bare wildcards that would match everything
763
+ const stripped = name.replace(/[*?]/g, '');
764
+ if (stripped.length === 0) {
765
+ return [];
766
+ }
696
767
  const globRegex = new RegExp('^' + name.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
697
768
  const matches = [];
698
769
  for (const [symName, symbols] of this.symbols) {
@@ -725,6 +796,7 @@ class ProjectIndex {
725
796
  }
726
797
 
727
798
  return this._applyFindFilters(matches, options);
799
+ } finally { this._endOp(); }
728
800
  }
729
801
 
730
802
  /**
@@ -747,6 +819,11 @@ class ProjectIndex {
747
819
  );
748
820
  }
749
821
 
822
+ // Skip expensive usage counting when caller doesn't need it
823
+ if (options.skipCounts) {
824
+ return filtered;
825
+ }
826
+
750
827
  // Add per-symbol usage counts for disambiguation
751
828
  const withCounts = filtered.map(m => {
752
829
  const counts = this.countSymbolUsages(m);
@@ -806,38 +883,27 @@ class ProjectIndex {
806
883
  if (!this.files.has(filePath)) continue;
807
884
 
808
885
  try {
809
- const content = this._readFile(filePath);
810
-
811
- // Try AST-based counting first
812
- const language = detectLanguage(filePath);
813
- const langModule = getLanguageModule(language);
814
-
815
- if (langModule && typeof langModule.findUsagesInCode === 'function') {
816
- try {
817
- const parser = getParser(language);
818
- if (parser) {
819
- const usages = langModule.findUsagesInCode(content, name, parser);
820
- // Deduplicate same-line same-type entries (e.g., `name: obj.name` has two AST nodes)
821
- const seen = new Set();
822
- for (const u of usages) {
823
- const key = `${filePath}:${u.line}:${u.usageType}`;
824
- if (seen.has(key)) continue;
825
- seen.add(key);
826
- switch (u.usageType) {
827
- case 'call': calls++; break;
828
- case 'definition': definitions++; break;
829
- case 'import': imports++; break;
830
- default: references++; break;
831
- }
832
- }
833
- continue; // Skip to next file
886
+ // Try AST-based counting first (with per-operation cache)
887
+ const astUsages = this._getCachedUsages(filePath, name);
888
+ if (astUsages !== null) {
889
+ // Deduplicate same-line same-type entries (e.g., `name: obj.name` has two AST nodes)
890
+ const seen = new Set();
891
+ for (const u of astUsages) {
892
+ const key = `${filePath}:${u.line}:${u.usageType}`;
893
+ if (seen.has(key)) continue;
894
+ seen.add(key);
895
+ switch (u.usageType) {
896
+ case 'call': calls++; break;
897
+ case 'definition': definitions++; break;
898
+ case 'import': imports++; break;
899
+ default: references++; break;
834
900
  }
835
- } catch (e) {
836
- // Fall through to regex-based counting
837
901
  }
902
+ continue; // Skip to next file
838
903
  }
839
904
 
840
905
  // Fallback: count regex matches as references (unsupported language)
906
+ const content = this._readFile(filePath);
841
907
  const lines = content.split('\n');
842
908
  lines.forEach((line) => {
843
909
  if (regex.test(line)) {
@@ -895,56 +961,48 @@ class ProjectIndex {
895
961
 
896
962
  try {
897
963
  const content = this._readFile(filePath);
898
- const lines = content.split('\n');
899
964
 
900
- // Try AST-based detection first
901
- const lang = detectLanguage(filePath);
902
- const langModule = getLanguageModule(lang);
965
+ // Fast pre-check: skip if name doesn't appear in file at all
966
+ if (!content.includes(name)) continue;
903
967
 
904
- if (langModule && typeof langModule.findUsagesInCode === 'function') {
905
- // AST-based detection
906
- try {
907
- const parser = getParser(lang);
908
- if (parser) {
909
- const astUsages = langModule.findUsagesInCode(content, name, parser);
910
-
911
- for (const u of astUsages) {
912
- // Skip if this is a definition line (already added above)
913
- if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
914
- continue;
915
- }
968
+ const lines = content.split('\n');
916
969
 
917
- const lineContent = lines[u.line - 1] || '';
970
+ // Try AST-based detection first (with per-operation cache)
971
+ const astUsages = this._getCachedUsages(filePath, name);
972
+ if (astUsages !== null) {
973
+ for (const u of astUsages) {
974
+ // Skip if this is a definition line (already added above)
975
+ if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
976
+ continue;
977
+ }
918
978
 
919
- const usage = {
920
- file: filePath,
921
- relativePath: fileEntry.relativePath,
922
- line: u.line,
923
- content: lineContent,
924
- usageType: u.usageType,
925
- isDefinition: false
926
- };
979
+ const lineContent = lines[u.line - 1] || '';
927
980
 
928
- // Add context lines if requested
929
- if (options.context && options.context > 0) {
930
- const idx = u.line - 1;
931
- const before = [];
932
- const after = [];
933
- for (let i = 1; i <= options.context; i++) {
934
- if (idx - i >= 0) before.unshift(lines[idx - i]);
935
- if (idx + i < lines.length) after.push(lines[idx + i]);
936
- }
937
- usage.before = before;
938
- usage.after = after;
939
- }
981
+ const usage = {
982
+ file: filePath,
983
+ relativePath: fileEntry.relativePath,
984
+ line: u.line,
985
+ content: lineContent,
986
+ usageType: u.usageType,
987
+ isDefinition: false
988
+ };
940
989
 
941
- usages.push(usage);
990
+ // Add context lines if requested
991
+ if (options.context && options.context > 0) {
992
+ const idx = u.line - 1;
993
+ const before = [];
994
+ const after = [];
995
+ for (let i = 1; i <= options.context; i++) {
996
+ if (idx - i >= 0) before.unshift(lines[idx - i]);
997
+ if (idx + i < lines.length) after.push(lines[idx + i]);
942
998
  }
943
- continue; // Skip to next file
999
+ usage.before = before;
1000
+ usage.after = after;
944
1001
  }
945
- } catch (e) {
946
- // Fall through to regex-based detection
1002
+
1003
+ usages.push(usage);
947
1004
  }
1005
+ continue; // Skip to next file
948
1006
  }
949
1007
 
950
1008
  // Fallback to regex-based detection
@@ -1037,7 +1095,8 @@ class ProjectIndex {
1037
1095
  }
1038
1096
 
1039
1097
  // Check Go/Rust-style receiver (e.g., func (r *Router) Method())
1040
- if (symbol.isMethod && symbol.receiver) {
1098
+ // Also matches Rust associated functions (have receiver but isMethod=false)
1099
+ if (symbol.receiver) {
1041
1100
  const receiverBase = symbol.receiver.replace(/^\*/, '');
1042
1101
  if (receiverBase === baseTypeName) {
1043
1102
  methods.push(symbol);
@@ -1080,6 +1139,12 @@ class ProjectIndex {
1080
1139
  if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
1081
1140
  const methods = this.findMethodsForType(name);
1082
1141
 
1142
+ let typeCallers = this.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain });
1143
+ // Apply exclude filter
1144
+ if (options.exclude && options.exclude.length > 0) {
1145
+ typeCallers = typeCallers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
1146
+ }
1147
+
1083
1148
  const result = {
1084
1149
  type: def.type,
1085
1150
  name: name,
@@ -1095,7 +1160,7 @@ class ProjectIndex {
1095
1160
  receiver: m.receiver
1096
1161
  })),
1097
1162
  // Also include places where the type is used in function parameters/returns
1098
- callers: this.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain })
1163
+ callers: typeCallers
1099
1164
  };
1100
1165
 
1101
1166
  if (warnings.length > 0) {
@@ -1154,343 +1219,11 @@ class ProjectIndex {
1154
1219
  } finally { this._endOp(); }
1155
1220
  }
1156
1221
 
1157
- /**
1158
- * Get cached calls for a file, parsing if necessary
1159
- * Uses mtime for fast cache validation, falls back to hash if mtime matches but content changed
1160
- * @param {string} filePath - Path to the file
1161
- * @param {object} [options] - Options
1162
- * @param {boolean} [options.includeContent] - Also return file content (avoids double read)
1163
- * @returns {Array|null|{calls: Array, content: string}} Array of calls, or object with content if requested
1164
- */
1165
- getCachedCalls(filePath, options = {}) {
1166
- try {
1167
- const cached = this.callsCache.get(filePath);
1168
-
1169
- // Fast path: check mtime first (stat is much faster than read+hash)
1170
- const stat = fs.statSync(filePath);
1171
- const mtime = stat.mtimeMs;
1172
-
1173
- if (cached && cached.mtime === mtime) {
1174
- // mtime matches - cache is likely valid
1175
- if (options.includeContent) {
1176
- // Need content, read if not cached
1177
- const content = cached.content || this._readFile(filePath);
1178
- return { calls: cached.calls, content };
1179
- }
1180
- return cached.calls;
1181
- }
1182
-
1183
- // mtime changed or no cache - need to read and possibly reparse
1184
- const content = this._readFile(filePath);
1185
- const hash = crypto.createHash('md5').update(content).digest('hex');
1186
-
1187
- // Check if content actually changed (mtime can change without content change)
1188
- if (cached && cached.hash === hash) {
1189
- // Content unchanged, just update mtime
1190
- cached.mtime = mtime;
1191
- cached.content = options.includeContent ? content : undefined;
1192
- if (options.includeContent) {
1193
- return { calls: cached.calls, content };
1194
- }
1195
- return cached.calls;
1196
- }
1197
-
1198
- // Content changed - need to reparse
1199
- const language = detectLanguage(filePath);
1200
- if (!language) return null;
1201
-
1202
- const langModule = getLanguageModule(language);
1203
- if (!langModule.findCallsInCode) return null;
1204
-
1205
- const parser = getParser(language);
1206
- const calls = langModule.findCallsInCode(content, parser);
1207
-
1208
- this.callsCache.set(filePath, {
1209
- mtime,
1210
- hash,
1211
- calls,
1212
- content: options.includeContent ? content : undefined
1213
- });
1214
-
1215
- if (options.includeContent) {
1216
- return { calls, content };
1217
- }
1218
- return calls;
1219
- } catch (e) {
1220
- return null;
1221
- }
1222
- }
1223
-
1224
- /**
1225
- * Find all callers of a function using AST-based detection
1226
- * @param {string} name - Function name to find callers for
1227
- * @param {object} [options] - Options
1228
- * @param {boolean} [options.includeMethods] - Include method calls (default: false)
1229
- */
1230
- findCallers(name, options = {}) {
1231
- this._beginOp();
1232
- try {
1233
- const callers = [];
1234
- const stats = options.stats;
1235
-
1236
- // Get definition lines to exclude them
1237
- const definitions = this.symbols.get(name) || [];
1238
- const definitionLines = new Set();
1239
- for (const def of definitions) {
1240
- definitionLines.add(`${def.file}:${def.startLine}`);
1241
- }
1242
-
1243
- for (const [filePath, fileEntry] of this.files) {
1244
- try {
1245
- const result = this.getCachedCalls(filePath, { includeContent: true });
1246
- if (!result) continue;
1247
-
1248
- const { calls, content } = result;
1249
- const lines = content.split('\n');
1250
-
1251
- for (const call of calls) {
1252
- // Skip if not matching our target name (also check alias resolution)
1253
- if (call.name !== name && call.resolvedName !== name &&
1254
- !(call.resolvedNames && call.resolvedNames.includes(name))) continue;
1255
-
1256
- // For potential callbacks (function passed as arg), validate against symbol table
1257
- // and skip complex binding resolution — just check the name exists
1258
- if (call.isPotentialCallback) {
1259
- const syms = definitions;
1260
- if (!syms || syms.length === 0) continue;
1261
- // Find the enclosing function
1262
- const callerSymbol = this.findEnclosingFunction(filePath, call.line, true);
1263
- callers.push({
1264
- file: filePath,
1265
- relativePath: fileEntry.relativePath,
1266
- line: call.line,
1267
- content: lines[call.line - 1] || '',
1268
- callerName: callerSymbol ? callerSymbol.name : null,
1269
- callerFile: callerSymbol ? filePath : null,
1270
- callerStartLine: callerSymbol ? callerSymbol.startLine : null,
1271
- callerEndLine: callerSymbol ? callerSymbol.endLine : null,
1272
- isMethod: false,
1273
- isFunctionReference: true
1274
- });
1275
- continue;
1276
- }
1277
-
1278
- // Resolve binding within this file (without mutating cached call objects)
1279
- let bindingId = call.bindingId;
1280
- let isUncertain = call.uncertain;
1281
- if (!bindingId) {
1282
- let bindings = (fileEntry.bindings || []).filter(b => b.name === call.name);
1283
- // For Go, also check sibling files in same directory (same package scope)
1284
- if (bindings.length === 0 && fileEntry.language === 'go') {
1285
- const dir = path.dirname(filePath);
1286
- for (const [fp, fe] of this.files) {
1287
- if (fp !== filePath && path.dirname(fp) === dir) {
1288
- const sibling = (fe.bindings || []).filter(b => b.name === call.name);
1289
- bindings = bindings.concat(sibling);
1290
- }
1291
- }
1292
- }
1293
- if (bindings.length === 1) {
1294
- bindingId = bindings[0].id;
1295
- } else if (bindings.length > 1 && !call.isMethod) {
1296
- // For implicit same-class calls (Java: execute() means this.execute()),
1297
- // try to resolve via caller's className before marking uncertain
1298
- const callerSym = this.findEnclosingFunction(filePath, call.line, true);
1299
- if (callerSym?.className) {
1300
- const callSymbols = this.symbols.get(call.name);
1301
- const sameClassSym = callSymbols?.find(s => s.className === callerSym.className);
1302
- if (sameClassSym) {
1303
- const matchingBinding = bindings.find(b => b.startLine === sameClassSym.startLine);
1304
- bindingId = matchingBinding?.id || sameClassSym.bindingId;
1305
- } else {
1306
- isUncertain = true;
1307
- }
1308
- } else {
1309
- // Scope-based disambiguation for shadowed functions:
1310
- // When multiple bindings exist, use indent level to determine
1311
- // which binding is in scope at the call site
1312
- const defs = this.symbols.get(call.name);
1313
- let resolved = false;
1314
- if (defs) {
1315
- // Sort bindings by indent desc (most nested first)
1316
- const scopedBindings = bindings.map(b => {
1317
- const sym = defs.find(s => s.startLine === b.startLine && s.file === filePath);
1318
- return { ...b, indent: sym?.indent ?? 0, endLine: sym?.endLine ?? b.startLine };
1319
- }).sort((a, b) => b.indent - a.indent);
1320
-
1321
- for (const sb of scopedBindings) {
1322
- if (sb.indent === 0) {
1323
- // Module-level binding — always in scope, use as fallback
1324
- bindingId = sb.id;
1325
- resolved = true;
1326
- break;
1327
- }
1328
- // Nested binding — check if call is inside its enclosing function
1329
- const enclosing = this.findEnclosingFunction(filePath, sb.startLine, true);
1330
- if (enclosing && call.line >= enclosing.startLine && call.line <= enclosing.endLine) {
1331
- // Call is inside the same function as this binding
1332
- bindingId = sb.id;
1333
- resolved = true;
1334
- break;
1335
- }
1336
- }
1337
- }
1338
- if (!resolved) isUncertain = true;
1339
- }
1340
- } else if (bindings.length > 1 && call.isMethod) {
1341
- // Multiple method bindings (e.g. Go String() on Reader vs Writer):
1342
- // Don't mark uncertain — include them even if conflated.
1343
- // Better to over-report than lose all callers.
1344
- } else if (bindings.length !== 0) {
1345
- isUncertain = true;
1346
- }
1347
- // Method call with no binding for the method name (JS/TS/Python only):
1348
- // Mark uncertain unless receiver has binding evidence in file scope.
1349
- // Go/Java/Rust excluded: callers are used for impact analysis where
1350
- // over-reporting is preferred to losing callers. These languages' nominal
1351
- // type systems also make method links more reliable.
1352
- if (bindings.length === 0 && call.isMethod &&
1353
- fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
1354
- const hasReceiverEvidence = call.receiver &&
1355
- (fileEntry.bindings || []).some(b => b.name === call.receiver);
1356
- if (!hasReceiverEvidence) {
1357
- isUncertain = true;
1358
- }
1359
- }
1360
- }
1361
-
1362
- // Smart method call handling — do this BEFORE uncertain check so
1363
- // self/this.method() calls can be resolved by same-class matching
1364
- // even when binding is ambiguous (e.g. method exists in multiple classes)
1365
- let resolvedBySameClass = false;
1366
- if (call.isMethod) {
1367
- if (call.selfAttribute && fileEntry.language === 'python') {
1368
- // self.attr.method() — resolve via attribute type inference
1369
- const callerSymbol = this.findEnclosingFunction(filePath, call.line, true);
1370
- if (!callerSymbol?.className) continue;
1371
- const attrTypes = this.getInstanceAttributeTypes(filePath, callerSymbol.className);
1372
- if (!attrTypes) continue;
1373
- const targetClass = attrTypes.get(call.selfAttribute);
1374
- if (!targetClass) continue;
1375
- // Check if any definition of searched function belongs to targetClass
1376
- const matchesDef = definitions.some(d => d.className === targetClass);
1377
- if (!matchesDef) continue;
1378
- resolvedBySameClass = true;
1379
- // Falls through to add as caller
1380
- } else if (['self', 'cls', 'this', 'super'].includes(call.receiver)) {
1381
- // self/this/super.method() — resolve to same-class or parent method
1382
- const callerSymbol = this.findEnclosingFunction(filePath, call.line, true);
1383
- if (!callerSymbol?.className) continue;
1384
- // For super(), skip same-class — only check parent chain
1385
- let matchesDef = call.receiver === 'super'
1386
- ? false
1387
- : definitions.some(d => d.className === callerSymbol.className);
1388
- // Walk inheritance chain using BFS if not found in same class
1389
- if (!matchesDef) {
1390
- const visited = new Set([callerSymbol.className]);
1391
- const callerFile = callerSymbol.file || filePath;
1392
- const startParents = this._getInheritanceParents(callerSymbol.className, callerFile) || [];
1393
- const queue = startParents.map(p => ({ name: p, contextFile: callerFile }));
1394
- while (queue.length > 0 && !matchesDef) {
1395
- const { name: current, contextFile } = queue.shift();
1396
- if (visited.has(current)) continue;
1397
- visited.add(current);
1398
- matchesDef = definitions.some(d => d.className === current);
1399
- if (!matchesDef) {
1400
- const resolvedFile = this._resolveClassFile(current, contextFile);
1401
- const grandparents = this._getInheritanceParents(current, resolvedFile) || [];
1402
- for (const gp of grandparents) {
1403
- if (!visited.has(gp)) queue.push({ name: gp, contextFile: resolvedFile });
1404
- }
1405
- }
1406
- }
1407
- }
1408
- if (!matchesDef) continue;
1409
- resolvedBySameClass = true;
1410
- // Falls through to add as caller
1411
- } else {
1412
- // Go doesn't use this/self/cls - always include Go method calls
1413
- // Java method calls are always obj.method() - include by default
1414
- // Rust Type::method() calls - include by default (associated functions)
1415
- // For other languages, skip method calls unless explicitly requested
1416
- if (fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust' && !options.includeMethods) continue;
1417
- }
1418
- }
1419
-
1420
- // Skip uncertain calls unless resolved by same-class matching or explicitly requested
1421
- if (isUncertain && !resolvedBySameClass && !options.includeUncertain) {
1422
- if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
1423
- continue;
1424
- }
1425
-
1426
- // Skip definition lines
1427
- if (definitionLines.has(`${filePath}:${call.line}`)) continue;
1428
-
1429
- // If we have a binding id on definition, require match when available
1430
- // When targetDefinitions is provided, only those definitions' bindings are valid targets
1431
- const targetDefs = options.targetDefinitions || definitions;
1432
- const targetBindingIds = new Set(targetDefs.map(d => d.bindingId).filter(Boolean));
1433
- if (targetBindingIds.size > 0 && bindingId && !targetBindingIds.has(bindingId)) {
1434
- continue;
1435
- }
1436
-
1437
- // Java/Go/Rust receiver-class disambiguation:
1438
- // When targetDefinitions narrows to specific class(es) and the call has a
1439
- // receiver (e.g. javascriptFileService.createDataFile()), check if the
1440
- // receiver name better matches a non-target class definition.
1441
- // This prevents false positives like reporting obj.save() as a caller of
1442
- // TargetClass.save() when obj is clearly a different type.
1443
- if (call.isMethod && call.receiver && !resolvedBySameClass && !bindingId &&
1444
- options.targetDefinitions && definitions.length > 1 &&
1445
- (fileEntry.language === 'java' || fileEntry.language === 'go' || fileEntry.language === 'rust')) {
1446
- const targetClassNames = new Set(targetDefs.map(d => d.className).filter(Boolean));
1447
- if (targetClassNames.size > 0) {
1448
- const receiverLower = call.receiver.toLowerCase();
1449
- // Check if receiver matches any target class (camelCase convention)
1450
- const matchesTarget = [...targetClassNames].some(cn => cn.toLowerCase() === receiverLower);
1451
- if (!matchesTarget) {
1452
- // Check if receiver matches a non-target class instead
1453
- const nonTargetClasses = definitions
1454
- .filter(d => d.className && !targetClassNames.has(d.className))
1455
- .map(d => d.className);
1456
- const matchesOther = nonTargetClasses.some(cn => cn.toLowerCase() === receiverLower);
1457
- if (matchesOther) {
1458
- // Receiver clearly belongs to a different class
1459
- isUncertain = true;
1460
- if (!options.includeUncertain) {
1461
- if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
1462
- continue;
1463
- }
1464
- }
1465
- }
1466
- }
1467
- }
1468
-
1469
- // Find the enclosing function (get full symbol info)
1470
- const callerSymbol = this.findEnclosingFunction(filePath, call.line, true);
1471
-
1472
- callers.push({
1473
- file: filePath,
1474
- relativePath: fileEntry.relativePath,
1475
- line: call.line,
1476
- content: lines[call.line - 1] || '',
1477
- callerName: callerSymbol ? callerSymbol.name : null,
1478
- callerFile: callerSymbol ? filePath : null,
1479
- callerStartLine: callerSymbol ? callerSymbol.startLine : null,
1480
- callerEndLine: callerSymbol ? callerSymbol.endLine : null,
1481
- isMethod: call.isMethod || false,
1482
- receiver: call.receiver
1483
- });
1484
- }
1485
- } catch (e) {
1486
- // Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
1487
- // These are not actionable errors — silently skip.
1488
- }
1489
- }
1222
+ /** Get cached call sites for a file, with mtime/hash validation */
1223
+ getCachedCalls(filePath, options) { return callersModule.getCachedCalls(this, filePath, options); }
1490
1224
 
1491
- return callers;
1492
- } finally { this._endOp(); }
1493
- }
1225
+ /** Find all callers of a function using AST-based detection */
1226
+ findCallers(name, options) { return callersModule.findCallers(this, name, options); }
1494
1227
 
1495
1228
  /**
1496
1229
  * Check if a name appears inside a string literal using AST
@@ -1537,481 +1270,91 @@ class ProjectIndex {
1537
1270
  }
1538
1271
  }
1539
1272
 
1273
+ /** Find all functions called by a function using AST-based detection */
1274
+ findCallees(def, options) { return callersModule.findCallees(this, def, options); }
1275
+
1276
+ /**
1277
+ * Calculate dependency weight based on usage
1278
+ */
1279
+ calculateWeight(callCount) {
1280
+ if (callCount >= 10) return 'core';
1281
+ if (callCount >= 3) return 'regular';
1282
+ if (callCount === 1) return 'utility';
1283
+ return 'normal';
1284
+ }
1285
+
1540
1286
  /**
1541
- * Find all functions called by a function using AST-based detection
1542
- * @param {object} def - Symbol definition with file, name, startLine, endLine
1543
- * @param {object} [options] - Options
1544
- * @param {boolean} [options.includeMethods] - Include method calls (default: false)
1287
+ * Smart extraction: function + dependencies
1545
1288
  */
1546
- findCallees(def, options = {}) {
1289
+ smart(name, options = {}) {
1547
1290
  this._beginOp();
1548
1291
  try {
1549
- try {
1550
- // Get all calls from the file's cache (now includes enclosingFunction)
1551
- const calls = this.getCachedCalls(def.file);
1552
- if (!calls) return [];
1553
-
1554
- // Get file language for smart method call handling
1555
- const fileEntry = this.files.get(def.file);
1556
- const language = fileEntry?.language;
1557
-
1558
- // Build list of inner class/struct method ranges to exclude from callee detection.
1559
- // Only class methods are excluded — they are independently addressable symbols.
1560
- // Calls within closures (named functions without className) ARE included as
1561
- // callees of the parent function, since closures are part of the parent's behavior.
1562
- const innerSymbolRanges = fileEntry ? fileEntry.symbols
1563
- .filter(s => !NON_CALLABLE_TYPES.has(s.type) &&
1564
- s.className && // Only exclude class methods, not closures
1565
- s.startLine > def.startLine && s.endLine <= def.endLine &&
1566
- s.startLine !== def.startLine)
1567
- .map(s => [s.startLine, s.endLine]) : [];
1568
-
1569
- const callees = new Map(); // key -> { name, bindingId, count }
1570
- let selfAttrCalls = null; // collected for Python self.attr.method() resolution
1571
- let selfMethodCalls = null; // collected for Python self.method() resolution
1572
-
1573
- for (const call of calls) {
1574
- // Filter to calls within this function's scope
1575
- // Method 1: Direct match via enclosingFunction (fast path for direct calls)
1576
- const isDirectMatch = call.enclosingFunction &&
1577
- call.enclosingFunction.startLine === def.startLine;
1578
- // Method 2: Line-range containment (catches calls inside nested callbacks/closures)
1579
- // A call is in our scope if it's within our line range AND not inside a named inner symbol
1580
- const isInRange = call.line >= def.startLine && call.line <= def.endLine;
1581
- const isInInnerSymbol = isInRange && innerSymbolRanges.some(
1582
- ([start, end]) => call.line >= start && call.line <= end);
1583
- const isNestedCallback = isInRange && !isInInnerSymbol && !isDirectMatch;
1584
-
1585
- if (!isDirectMatch && !isNestedCallback) continue;
1586
-
1587
- // Smart method call handling:
1588
- // - Go: include all method calls (Go doesn't use this/self/cls)
1589
- // - self/this.method(): resolve to same-class method (handled below)
1590
- // - Python self.attr.method(): resolve via selfAttribute (handled below)
1591
- // - Other languages: skip method calls unless explicitly requested
1592
- if (call.isMethod) {
1593
- if (call.selfAttribute && language === 'python') {
1594
- // Will be resolved in second pass below
1595
- } else if (['self', 'cls', 'this'].includes(call.receiver)) {
1596
- // self.method() / cls.method() / this.method() — resolve to same-class method below
1597
- } else if (call.receiver === 'super') {
1598
- // super().method() — resolve to parent class method below
1599
- } else if (language !== 'go' && language !== 'java' && language !== 'rust' && !options.includeMethods) {
1600
- continue;
1601
- }
1602
- }
1292
+ const { def } = this.resolveSymbol(name, { file: options.file });
1293
+ if (!def) {
1294
+ return null;
1295
+ }
1296
+ const code = this.extractCode(def);
1297
+ const stats = { uncertain: 0 };
1298
+ const callees = this.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
1603
1299
 
1604
- // Skip keywords and built-ins
1605
- if (this.isKeyword(call.name, language)) continue;
1300
+ const filesInScope = new Set([def.file]);
1301
+ callees.forEach(c => filesInScope.add(c.file));
1302
+ let dynamicImports = 0;
1303
+ for (const f of filesInScope) {
1304
+ const fe = this.files.get(f);
1305
+ if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
1306
+ }
1606
1307
 
1607
- // Use resolved name (from alias tracking) if available
1608
- // For multi-target aliases (ternary), pick the first that exists in symbol table
1609
- let effectiveName = call.resolvedName || call.name;
1610
- if (call.resolvedNames) {
1611
- for (const rn of call.resolvedNames) {
1612
- if (this.symbols.has(rn)) { effectiveName = rn; break; }
1613
- }
1614
- }
1308
+ // Extract code for each dependency, excluding the exact same function
1309
+ // (but keeping same-name overloads, e.g. Java toJson(Object) vs toJson(Object, Class))
1310
+ const defBindingId = def.bindingId;
1311
+ const dependencies = callees
1312
+ .filter(callee => callee.bindingId !== defBindingId)
1313
+ .map(callee => ({
1314
+ ...callee,
1315
+ code: this.extractCode(callee)
1316
+ }));
1615
1317
 
1616
- // For potential callbacks (identifier args to non-HOF calls),
1617
- // only include if name exists as a function in symbol table
1618
- // AND has binding/import evidence or same-file definition.
1619
- // Prevents local variables (request, context) from matching
1620
- // unrelated functions defined elsewhere (especially test files).
1621
- if (call.isPotentialCallback) {
1622
- const syms = this.symbols.get(effectiveName);
1623
- if (!syms || !syms.some(s =>
1624
- ['function', 'method', 'constructor', 'static', 'public', 'abstract'].includes(s.type))) {
1625
- continue;
1626
- }
1627
- const hasBinding = fileEntry?.bindings?.some(b => b.name === call.name);
1628
- const inSameFile = syms.some(s => s.file === def.file);
1629
- if (!hasBinding && !inSameFile) {
1630
- continue;
1318
+ // Find type definitions if requested
1319
+ const types = [];
1320
+ if (options.withTypes) {
1321
+ // Look for type annotations in params/return type
1322
+ const typeNames = this.extractTypeNames(def);
1323
+ for (const typeName of typeNames) {
1324
+ const typeSymbols = this.symbols.get(typeName);
1325
+ if (typeSymbols) {
1326
+ for (const sym of typeSymbols) {
1327
+ if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
1328
+ types.push({
1329
+ ...sym,
1330
+ code: this.extractCode(sym)
1331
+ });
1332
+ }
1631
1333
  }
1632
1334
  }
1335
+ }
1336
+ }
1633
1337
 
1634
- // Collect selfAttribute calls for second-pass resolution
1635
- if (call.selfAttribute && language === 'python') {
1636
- if (!selfAttrCalls) selfAttrCalls = [];
1637
- selfAttrCalls.push(call);
1638
- continue;
1639
- }
1640
-
1641
- // Collect self/this.method() calls for same-class resolution
1642
- if (call.isMethod && ['self', 'cls', 'this'].includes(call.receiver)) {
1643
- if (!selfMethodCalls) selfMethodCalls = [];
1644
- selfMethodCalls.push(call);
1645
- continue;
1646
- }
1338
+ return {
1339
+ target: {
1340
+ ...def,
1341
+ code
1342
+ },
1343
+ dependencies,
1344
+ types,
1345
+ meta: {
1346
+ complete: stats.uncertain === 0 && dynamicImports === 0,
1347
+ skipped: 0,
1348
+ dynamicImports,
1349
+ uncertain: stats.uncertain
1350
+ }
1351
+ };
1352
+ } finally { this._endOp(); }
1353
+ }
1647
1354
 
1648
- // Collect super().method() calls for parent-class resolution
1649
- if (call.isMethod && call.receiver === 'super') {
1650
- if (!selfMethodCalls) selfMethodCalls = [];
1651
- selfMethodCalls.push(call);
1652
- continue;
1653
- }
1654
-
1655
- // Resolve binding within this file (without mutating cached call objects)
1656
- let calleeKey = call.bindingId || effectiveName;
1657
- let bindingResolved = call.bindingId;
1658
- let isUncertain = call.uncertain;
1659
- if (!call.bindingId && fileEntry?.bindings) {
1660
- let bindings = fileEntry.bindings.filter(b => b.name === call.name);
1661
- // For Go, also check sibling files in same directory (same package scope)
1662
- if (bindings.length === 0 && language === 'go') {
1663
- const dir = path.dirname(def.file);
1664
- for (const [fp, fe] of this.files) {
1665
- if (fp !== def.file && path.dirname(fp) === dir) {
1666
- const sibling = (fe.bindings || []).filter(b => b.name === call.name);
1667
- bindings = bindings.concat(sibling);
1668
- }
1669
- }
1670
- }
1671
- // Method call with no binding for the method name:
1672
- // Different strategies by language family:
1673
- if (bindings.length === 0 && call.isMethod) {
1674
- if (language !== 'go' && language !== 'java' && language !== 'rust') {
1675
- // JS/TS/Python: mark uncertain unless receiver has import/binding
1676
- // evidence in file scope. Prevents false positives like m.get() →
1677
- // repository.get() when m is just a parameter with no type info.
1678
- const hasReceiverEvidence = call.receiver &&
1679
- fileEntry?.bindings?.some(b => b.name === call.receiver);
1680
- if (!hasReceiverEvidence) {
1681
- isUncertain = true;
1682
- }
1683
- } else {
1684
- // Go/Java/Rust: nominal type systems make single-def method links
1685
- // reliable. Only mark uncertain when multiple definitions exist
1686
- // (cross-type ambiguity, e.g. TypeA.Length vs TypeB.Length).
1687
- const defs = this.symbols.get(call.name);
1688
- if (defs && defs.length > 1) {
1689
- isUncertain = true;
1690
- }
1691
- }
1692
- }
1693
- if (bindings.length === 1) {
1694
- bindingResolved = bindings[0].id;
1695
- calleeKey = bindingResolved;
1696
- } else if (bindings.length > 1) {
1697
- if (call.name === def.name) {
1698
- // Calling same-name function (e.g., Java overloads)
1699
- // Add ALL other overloads as potential callees
1700
- const otherBindings = bindings.filter(b =>
1701
- b.startLine !== def.startLine
1702
- );
1703
- for (const ob of otherBindings) {
1704
- const existing = callees.get(ob.id);
1705
- if (existing) {
1706
- existing.count += 1;
1707
- } else {
1708
- callees.set(ob.id, {
1709
- name: effectiveName,
1710
- bindingId: ob.id,
1711
- count: 1
1712
- });
1713
- }
1714
- }
1715
- continue; // Already added all overloads, skip normal add
1716
- } else if (def.className && !call.isMethod) {
1717
- // Implicit same-class call (Java: execute() means this.execute())
1718
- // Try to resolve to a binding in the same class via symbol lookup
1719
- const callSymbols = this.symbols.get(call.name);
1720
- if (callSymbols) {
1721
- const sameClassSym = callSymbols.find(s => s.className === def.className);
1722
- if (sameClassSym) {
1723
- // Find the binding that matches this symbol's line
1724
- const matchingBinding = bindings.find(b => b.startLine === sameClassSym.startLine);
1725
- if (matchingBinding) {
1726
- bindingResolved = matchingBinding.id;
1727
- calleeKey = bindingResolved;
1728
- } else {
1729
- bindingResolved = sameClassSym.bindingId;
1730
- calleeKey = bindingResolved || `${def.className}.${call.name}`;
1731
- }
1732
- } else {
1733
- isUncertain = true;
1734
- }
1735
- } else {
1736
- isUncertain = true;
1737
- }
1738
- } else {
1739
- // Try to resolve to a binding defined within the parent function's
1740
- // scope (inner closure). E.g., hookRunnerApplication defines next()
1741
- // internally — prefer that over other next() in the same file.
1742
- const innerBinding = bindings.find(b =>
1743
- b.startLine > def.startLine && b.startLine <= def.endLine);
1744
- if (innerBinding) {
1745
- bindingResolved = innerBinding.id;
1746
- calleeKey = bindingResolved;
1747
- } else {
1748
- isUncertain = true;
1749
- }
1750
- }
1751
- }
1752
- }
1753
-
1754
- if (isUncertain && !options.includeUncertain) {
1755
- if (options.stats) options.stats.uncertain = (options.stats.uncertain || 0) + 1;
1756
- continue;
1757
- }
1758
-
1759
- const existing = callees.get(calleeKey);
1760
- if (existing) {
1761
- existing.count += 1;
1762
- } else {
1763
- callees.set(calleeKey, {
1764
- name: effectiveName,
1765
- bindingId: bindingResolved,
1766
- count: 1
1767
- });
1768
- }
1769
- }
1770
-
1771
- // Second pass: resolve Python self.attr.method() calls
1772
- if (selfAttrCalls && def.className) {
1773
- const attrTypes = this.getInstanceAttributeTypes(def.file, def.className);
1774
- if (attrTypes) {
1775
- for (const call of selfAttrCalls) {
1776
- const targetClass = attrTypes.get(call.selfAttribute);
1777
- if (!targetClass) continue;
1778
-
1779
- // Find method in symbol table where className matches
1780
- const symbols = this.symbols.get(call.name);
1781
- if (!symbols) continue;
1782
-
1783
- const match = symbols.find(s => s.className === targetClass);
1784
- if (!match) continue;
1785
-
1786
- const key = match.bindingId || `${targetClass}.${call.name}`;
1787
- const existing = callees.get(key);
1788
- if (existing) {
1789
- existing.count += 1;
1790
- } else {
1791
- callees.set(key, {
1792
- name: call.name,
1793
- bindingId: match.bindingId,
1794
- count: 1
1795
- });
1796
- }
1797
- }
1798
- }
1799
- }
1800
-
1801
- // Third pass: resolve self/this/super.method() calls to same-class or parent methods
1802
- // Falls back to walking the inheritance chain if not found in same class
1803
- if (selfMethodCalls && def.className) {
1804
- for (const call of selfMethodCalls) {
1805
- const symbols = this.symbols.get(call.name);
1806
- if (!symbols) continue;
1807
-
1808
- // For super().method(), skip same-class — start from parent
1809
- let match = call.receiver === 'super'
1810
- ? null
1811
- : symbols.find(s => s.className === def.className);
1812
-
1813
- // Walk inheritance chain using BFS if not found in same class
1814
- if (!match) {
1815
- const visited = new Set([def.className]);
1816
- const defFile = def.file;
1817
- const startParents = this._getInheritanceParents(def.className, defFile) || [];
1818
- const queue = startParents.map(p => ({ name: p, contextFile: defFile }));
1819
- while (queue.length > 0 && !match) {
1820
- const { name: current, contextFile } = queue.shift();
1821
- if (visited.has(current)) continue;
1822
- visited.add(current);
1823
- match = symbols.find(s => s.className === current);
1824
- if (!match) {
1825
- const resolvedFile = this._resolveClassFile(current, contextFile);
1826
- const grandparents = this._getInheritanceParents(current, resolvedFile) || [];
1827
- for (const gp of grandparents) {
1828
- if (!visited.has(gp)) queue.push({ name: gp, contextFile: resolvedFile });
1829
- }
1830
- }
1831
- }
1832
- }
1833
-
1834
- if (!match) continue;
1835
-
1836
- const key = match.bindingId || `${match.className}.${call.name}`;
1837
- const existing = callees.get(key);
1838
- if (existing) {
1839
- existing.count += 1;
1840
- } else {
1841
- callees.set(key, {
1842
- name: call.name,
1843
- bindingId: match.bindingId,
1844
- count: 1
1845
- });
1846
- }
1847
- }
1848
- }
1849
-
1850
- // Look up each callee in the symbol table
1851
- // For methods, prefer callees from: 1) same file, 2) same package, 3) same receiver type
1852
- // Also deprioritize test-file definitions when caller is in production code
1853
- const result = [];
1854
- const defDir = path.dirname(def.file);
1855
- const defReceiver = def.receiver;
1856
- const defFileEntry = fileEntry;
1857
- const callerIsTest = defFileEntry && isTestFile(defFileEntry.relativePath, defFileEntry.language);
1858
-
1859
- for (const { name: calleeName, bindingId, count } of callees.values()) {
1860
- const symbols = this.symbols.get(calleeName);
1861
- if (symbols && symbols.length > 0) {
1862
- let callee = symbols[0];
1863
-
1864
- // If we have a binding ID, find the exact matching symbol
1865
- if (bindingId && symbols.length > 1) {
1866
- const exactMatch = symbols.find(s => s.bindingId === bindingId);
1867
- if (exactMatch) {
1868
- callee = exactMatch;
1869
- }
1870
- } else if (symbols.length > 1) {
1871
- // Priority 1: Same file, but different definition (for overloads)
1872
- const sameFileDifferent = symbols.find(s => s.file === def.file && s.startLine !== def.startLine);
1873
- const sameFile = symbols.find(s => s.file === def.file);
1874
- if (sameFileDifferent && calleeName === def.name) {
1875
- callee = sameFileDifferent;
1876
- } else if (sameFile) {
1877
- callee = sameFile;
1878
- } else {
1879
- // Priority 2: Same directory (package)
1880
- const sameDir = symbols.find(s => path.dirname(s.file) === defDir);
1881
- if (sameDir) {
1882
- callee = sameDir;
1883
- } else if (defReceiver) {
1884
- // Priority 3: Same receiver type (for methods)
1885
- const sameReceiver = symbols.find(s => s.receiver === defReceiver);
1886
- if (sameReceiver) {
1887
- callee = sameReceiver;
1888
- }
1889
- }
1890
- }
1891
- // Priority 4: If default (symbols[0]) is a test file, prefer non-test
1892
- if (!bindingId) {
1893
- const calleeFileEntry = this.files.get(callee.file);
1894
- if (calleeFileEntry && isTestFile(calleeFileEntry.relativePath, calleeFileEntry.language)) {
1895
- const nonTest = symbols.find(s => {
1896
- const fe = this.files.get(s.file);
1897
- return fe && !isTestFile(fe.relativePath, fe.language);
1898
- });
1899
- if (nonTest) callee = nonTest;
1900
- }
1901
- }
1902
- }
1903
-
1904
- // Skip test-file callees when caller is production code and
1905
- // there's no binding (import) evidence linking them
1906
- if (!callerIsTest && !bindingId) {
1907
- const calleeFileEntry = this.files.get(callee.file);
1908
- if (calleeFileEntry && isTestFile(calleeFileEntry.relativePath, calleeFileEntry.language)) {
1909
- continue;
1910
- }
1911
- }
1912
-
1913
- result.push({
1914
- ...callee,
1915
- callCount: count,
1916
- weight: this.calculateWeight(count)
1917
- });
1918
- }
1919
- }
1920
-
1921
- // Sort by call count (core dependencies first)
1922
- result.sort((a, b) => b.callCount - a.callCount);
1923
-
1924
- return result;
1925
- } catch (e) {
1926
- // Expected: file read/parse failures (minified, binary, buffer exceeded).
1927
- // Return empty callees rather than crashing the entire query.
1928
- return [];
1929
- }
1930
- } finally { this._endOp(); }
1931
- }
1932
-
1933
- /**
1934
- * Calculate dependency weight based on usage
1935
- */
1936
- calculateWeight(callCount) {
1937
- if (callCount >= 10) return 'core';
1938
- if (callCount >= 3) return 'regular';
1939
- if (callCount === 1) return 'utility';
1940
- return 'normal';
1941
- }
1942
-
1943
- /**
1944
- * Smart extraction: function + dependencies
1945
- */
1946
- smart(name, options = {}) {
1947
- this._beginOp();
1948
- try {
1949
- const { def } = this.resolveSymbol(name, { file: options.file });
1950
- if (!def) {
1951
- return null;
1952
- }
1953
- const code = this.extractCode(def);
1954
- const stats = { uncertain: 0 };
1955
- const callees = this.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
1956
-
1957
- const filesInScope = new Set([def.file]);
1958
- callees.forEach(c => filesInScope.add(c.file));
1959
- let dynamicImports = 0;
1960
- for (const f of filesInScope) {
1961
- const fe = this.files.get(f);
1962
- if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
1963
- }
1964
-
1965
- // Extract code for each dependency, excluding the exact same function
1966
- // (but keeping same-name overloads, e.g. Java toJson(Object) vs toJson(Object, Class))
1967
- const defBindingId = def.bindingId;
1968
- const dependencies = callees
1969
- .filter(callee => callee.bindingId !== defBindingId)
1970
- .map(callee => ({
1971
- ...callee,
1972
- code: this.extractCode(callee)
1973
- }));
1974
-
1975
- // Find type definitions if requested
1976
- const types = [];
1977
- if (options.withTypes) {
1978
- // Look for type annotations in params/return type
1979
- const typeNames = this.extractTypeNames(def);
1980
- for (const typeName of typeNames) {
1981
- const typeSymbols = this.symbols.get(typeName);
1982
- if (typeSymbols) {
1983
- for (const sym of typeSymbols) {
1984
- if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
1985
- types.push({
1986
- ...sym,
1987
- code: this.extractCode(sym)
1988
- });
1989
- }
1990
- }
1991
- }
1992
- }
1993
- }
1994
-
1995
- return {
1996
- target: {
1997
- ...def,
1998
- code
1999
- },
2000
- dependencies,
2001
- types,
2002
- meta: {
2003
- complete: stats.uncertain === 0 && dynamicImports === 0,
2004
- skipped: 0,
2005
- dynamicImports,
2006
- uncertain: stats.uncertain
2007
- }
2008
- };
2009
- } finally { this._endOp(); }
2010
- }
2011
-
2012
- // ========================================================================
2013
- // HELPER METHODS
2014
- // ========================================================================
1355
+ // ========================================================================
1356
+ // HELPER METHODS
1357
+ // ========================================================================
2015
1358
 
2016
1359
  /**
2017
1360
  * Get line content from a file
@@ -2206,34 +1549,8 @@ class ProjectIndex {
2206
1549
  return returnSymbol ? best : best.name;
2207
1550
  }
2208
1551
 
2209
- /**
2210
- * Get instance attribute types for a class in a file.
2211
- * Returns Map<attrName, typeName> for a given className.
2212
- * Caches results per file.
2213
- */
2214
- getInstanceAttributeTypes(filePath, className) {
2215
- if (!this._attrTypeCache) this._attrTypeCache = new Map();
2216
-
2217
- let fileCache = this._attrTypeCache.get(filePath);
2218
- if (!fileCache) {
2219
- const fileEntry = this.files.get(filePath);
2220
- if (!fileEntry || fileEntry.language !== 'python') return null;
2221
-
2222
- const langModule = getLanguageModule('python');
2223
- if (!langModule?.findInstanceAttributeTypes) return null;
2224
-
2225
- try {
2226
- const content = this._readFile(filePath);
2227
- const parser = getParser('python');
2228
- fileCache = langModule.findInstanceAttributeTypes(content, parser);
2229
- this._attrTypeCache.set(filePath, fileCache);
2230
- } catch {
2231
- return null;
2232
- }
2233
- }
2234
-
2235
- return fileCache.get(className) || null;
2236
- }
1552
+ /** Get instance attribute types for a class in a file */
1553
+ getInstanceAttributeTypes(filePath, className) { return callersModule.getInstanceAttributeTypes(this, filePath, className); }
2237
1554
 
2238
1555
  /**
2239
1556
  * Extract type names from a function definition
@@ -2284,16 +1601,30 @@ class ProjectIndex {
2284
1601
  const content = this._readFile(normalizedPath);
2285
1602
  const { imports: rawImports } = extractImports(content, fileEntry.language);
2286
1603
 
1604
+ const contentLines = content.split('\n');
1605
+
2287
1606
  return rawImports.map(imp => {
1607
+ // Skip imports with null module (e.g. Rust include! with dynamic path)
1608
+ if (!imp.module) {
1609
+ return {
1610
+ module: null,
1611
+ names: imp.names,
1612
+ type: imp.type,
1613
+ resolved: null,
1614
+ isExternal: false,
1615
+ isDynamic: true,
1616
+ line: null
1617
+ };
1618
+ }
1619
+
2288
1620
  // Dynamic imports with variable path (e.g. require(varName), import(varExpr)) can't be resolved.
2289
1621
  // Only JS/TS require()/import() with dynamic=true has unresolvable paths.
2290
1622
  // Go side-effect/dot imports and Rust glob uses also set dynamic=true but have valid module paths.
2291
1623
  const isUnresolvableDynamic = imp.dynamic && (imp.type === 'require' || imp.type === 'dynamic');
2292
1624
  if (isUnresolvableDynamic) {
2293
- const lines = content.split('\n');
2294
1625
  let line = null;
2295
- for (let i = 0; i < lines.length; i++) {
2296
- if (lines[i].includes(imp.module || 'require')) {
1626
+ for (let i = 0; i < contentLines.length; i++) {
1627
+ if (contentLines[i].includes(imp.module || 'require')) {
2297
1628
  line = i + 1;
2298
1629
  break;
2299
1630
  }
@@ -2322,10 +1653,9 @@ class ProjectIndex {
2322
1653
  }
2323
1654
 
2324
1655
  // Find line number of import
2325
- const lines = content.split('\n');
2326
1656
  let line = null;
2327
- for (let i = 0; i < lines.length; i++) {
2328
- if (lines[i].includes(imp.module)) {
1657
+ for (let i = 0; i < contentLines.length; i++) {
1658
+ if (contentLines[i].includes(imp.module)) {
2329
1659
  line = i + 1;
2330
1660
  break;
2331
1661
  }
@@ -2616,11 +1946,15 @@ class ProjectIndex {
2616
1946
  * @param {string} filePath - File path
2617
1947
  * @returns {Array} Exported symbols from that file
2618
1948
  */
2619
- fileExports(filePath) {
1949
+ fileExports(filePath, _visited) {
2620
1950
  const resolved = this.resolveFilePathForQuery(filePath);
2621
1951
  if (typeof resolved !== 'string') return resolved;
2622
1952
 
2623
1953
  const absPath = resolved;
1954
+ const visited = _visited || new Set();
1955
+ if (visited.has(absPath)) return [];
1956
+ visited.add(absPath);
1957
+
2624
1958
  const fileEntry = this.files.get(absPath);
2625
1959
  if (!fileEntry) {
2626
1960
  return [];
@@ -2683,7 +2017,7 @@ class ProjectIndex {
2683
2017
  if (sourceEntry) {
2684
2018
  // For star re-exports, include all exported symbols from source
2685
2019
  if (exp.type === 're-export-all') {
2686
- const sourceExports = this.fileExports(resolved);
2020
+ const sourceExports = this.fileExports(resolved, visited);
2687
2021
  for (const srcExp of sourceExports) {
2688
2022
  if (!matchedNames.has(srcExp.name)) {
2689
2023
  matchedNames.add(srcExp.name);
@@ -2731,396 +2065,64 @@ class ProjectIndex {
2731
2065
  return results;
2732
2066
  }
2733
2067
 
2734
- /**
2735
- * Check if a function is used as a callback anywhere in the codebase
2736
- * @param {string} name - Function name
2737
- * @returns {Array} Callback usages
2738
- */
2739
- findCallbackUsages(name) {
2740
- const usages = [];
2068
+ /** Check if a function is used as a callback anywhere in the codebase */
2069
+ findCallbackUsages(name) { return callersModule.findCallbackUsages(this, name); }
2741
2070
 
2742
- for (const [filePath, fileEntry] of this.files) {
2743
- try {
2744
- const content = this._readFile(filePath);
2745
- const language = detectLanguage(filePath);
2746
- if (!language) continue;
2747
2071
 
2748
- const langModule = getLanguageModule(language);
2749
- if (!langModule.findCallbackUsages) continue;
2072
+ /** Build a usage index for all identifiers in the codebase (optimized for deadcode) */
2073
+ buildUsageIndex() { return deadcodeModule.buildUsageIndex(this); }
2750
2074
 
2751
- const parser = getParser(language);
2752
- const callbacks = langModule.findCallbackUsages(content, name, parser);
2075
+ /** Find dead code (unused functions/classes) */
2076
+ deadcode(options) { return deadcodeModule.deadcode(this, options); }
2753
2077
 
2754
- for (const cb of callbacks) {
2755
- usages.push({
2756
- file: filePath,
2757
- relativePath: fileEntry.relativePath,
2758
- ...cb
2759
- });
2760
- }
2761
- } catch (e) {
2762
- // Skip files that can't be processed
2763
- }
2764
- }
2078
+ /**
2079
+ * Get dependency graph for a file
2080
+ * @param {string} filePath - Starting file
2081
+ * @param {object} options - { direction: 'imports' | 'importers' | 'both', maxDepth }
2082
+ * @returns {object} - Graph structure with root, nodes, edges
2083
+ */
2084
+ graph(filePath, options = {}) {
2085
+ const direction = options.direction || 'both';
2086
+ // Sanitize depth: use default for null/undefined, clamp negative to 0
2087
+ const rawDepth = options.maxDepth ?? 5;
2088
+ const maxDepth = Math.max(0, rawDepth);
2765
2089
 
2766
- return usages;
2767
- }
2090
+ const resolved = this.resolveFilePathForQuery(filePath);
2091
+ if (typeof resolved !== 'string') return resolved;
2768
2092
 
2093
+ const targetPath = resolved;
2769
2094
 
2770
- /**
2771
- * Build a usage index for all identifiers in the codebase (optimized for deadcode)
2772
- * Scans all files ONCE and builds a reverse index: name -> [usages]
2773
- * @returns {Map<string, Array>} Usage index
2774
- */
2775
- buildUsageIndex() {
2776
- const usageIndex = new Map(); // name -> [{file, line}]
2095
+ const buildSubgraph = (dir) => {
2096
+ const visited = new Set();
2097
+ const nodes = [];
2098
+ const edges = [];
2777
2099
 
2778
- for (const [filePath, fileEntry] of this.files) {
2779
- try {
2780
- const language = detectLanguage(filePath);
2781
- if (!language) continue;
2100
+ const traverse = (file, depth) => {
2101
+ if (visited.has(file)) return;
2102
+ visited.add(file);
2782
2103
 
2783
- const content = this._readFile(filePath);
2104
+ const fileEntry = this.files.get(file);
2105
+ const relPath = fileEntry ? fileEntry.relativePath : path.relative(this.root, file);
2106
+ nodes.push({ file, relativePath: relPath, depth });
2784
2107
 
2785
- // For HTML files, parse the virtual JS content instead of raw HTML
2786
- // (HTML tree-sitter sees script content as raw_text, not JS identifiers)
2787
- let tree;
2788
- if (language === 'html') {
2789
- const htmlModule = getLanguageModule('html');
2790
- const htmlParser = getParser('html');
2791
- const jsParser = getParser('javascript');
2792
- const blocks = htmlModule.extractScriptBlocks(content, htmlParser);
2793
- if (blocks.length === 0 && !htmlModule.extractEventHandlerCalls) continue;
2794
- if (blocks.length > 0) {
2795
- const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
2796
- tree = safeParse(jsParser, virtualJS);
2797
- }
2108
+ // Stop traversal at max depth but still register the node above
2109
+ if (depth >= maxDepth) return;
2110
+
2111
+ let neighbors = [];
2112
+ if (dir === 'imports') {
2113
+ neighbors = this.importGraph.get(file) || [];
2798
2114
  } else {
2799
- const parser = getParser(language);
2800
- if (!parser) continue;
2801
- tree = safeParse(parser, content);
2115
+ neighbors = this.exportGraph.get(file) || [];
2802
2116
  }
2803
2117
 
2804
- // Collect all identifiers from this file in one pass
2805
- const traverse = (node) => {
2806
- // Match all identifier-like nodes across languages
2807
- if (node.type === 'identifier' ||
2808
- node.type === 'property_identifier' ||
2809
- node.type === 'type_identifier' ||
2810
- node.type === 'shorthand_property_identifier' ||
2811
- node.type === 'shorthand_property_identifier_pattern' ||
2812
- node.type === 'field_identifier') {
2813
- const name = node.text;
2814
- if (!usageIndex.has(name)) {
2815
- usageIndex.set(name, []);
2816
- }
2817
- usageIndex.get(name).push({
2818
- file: filePath,
2819
- line: node.startPosition.row + 1,
2820
- relativePath: fileEntry.relativePath
2821
- });
2822
- }
2823
- for (let i = 0; i < node.childCount; i++) {
2824
- traverse(node.child(i));
2825
- }
2826
- };
2827
- if (tree) traverse(tree.rootNode);
2828
-
2829
- // For HTML files, also extract identifiers from event handler attributes
2830
- // (onclick="foo()" etc. — these are in HTML, not in <script> blocks)
2831
- if (language === 'html') {
2832
- const htmlModule = getLanguageModule('html');
2833
- const htmlParser = getParser('html');
2834
- const handlerCalls = htmlModule.extractEventHandlerCalls(content, htmlParser);
2835
- for (const call of handlerCalls) {
2836
- if (!usageIndex.has(call.name)) {
2837
- usageIndex.set(call.name, []);
2838
- }
2839
- usageIndex.get(call.name).push({
2840
- file: filePath,
2841
- line: call.line,
2842
- relativePath: fileEntry.relativePath
2843
- });
2844
- }
2118
+ // Deduplicate neighbors (same file may be imported multiple times, e.g. Java inner classes)
2119
+ const uniqueNeighbors = [...new Set(neighbors)];
2120
+
2121
+ for (const neighbor of uniqueNeighbors) {
2122
+ edges.push({ from: file, to: neighbor });
2123
+ traverse(neighbor, depth + 1);
2845
2124
  }
2846
- } catch (e) {
2847
- // Skip files that can't be processed
2848
- }
2849
- }
2850
-
2851
- return usageIndex;
2852
- }
2853
-
2854
- /**
2855
- * Find dead code (unused functions/classes)
2856
- * @param {object} options - { includeExported, includeTests }
2857
- * @returns {Array} Unused symbols
2858
- */
2859
- deadcode(options = {}) {
2860
- this._beginOp();
2861
- try {
2862
- const results = [];
2863
- let excludedDecorated = 0;
2864
- let excludedExported = 0;
2865
-
2866
- // Build usage index once (instead of per-symbol)
2867
- const usageIndex = this.buildUsageIndex();
2868
-
2869
- for (const [name, symbols] of this.symbols) {
2870
- for (const symbol of symbols) {
2871
- // Skip non-function/class types
2872
- // Include various method types from different languages:
2873
- // - function: standalone functions
2874
- // - class, struct, interface: type definitions (skip them in deadcode)
2875
- // - method: class methods
2876
- // - static, public, abstract: Java method modifiers used as types
2877
- // - constructor: constructors
2878
- const callableTypes = ['function', 'method', 'static', 'public', 'abstract', 'constructor'];
2879
- if (!callableTypes.includes(symbol.type)) {
2880
- continue;
2881
- }
2882
-
2883
- const fileEntry = this.files.get(symbol.file);
2884
- const lang = fileEntry?.language;
2885
-
2886
- // Skip bundled/minified files (webpack bundles, build artifacts)
2887
- if (fileEntry?.isBundled) {
2888
- continue;
2889
- }
2890
-
2891
- // Skip test files unless requested
2892
- if (!options.includeTests && isTestFile(symbol.relativePath, lang)) {
2893
- continue;
2894
- }
2895
-
2896
- // Apply exclude and in filters
2897
- if ((options.exclude && options.exclude.length > 0) || options.in) {
2898
- if (!this.matchesFilters(symbol.relativePath, { exclude: options.exclude, in: options.in })) {
2899
- continue;
2900
- }
2901
- }
2902
-
2903
- const mods = symbol.modifiers || [];
2904
-
2905
- // Language-specific entry points (called by runtime, no AST-visible callers)
2906
- // Go: main() and init() are called by runtime
2907
- const isGoEntryPoint = lang === 'go' && (name === 'main' || name === 'init');
2908
-
2909
- // Java: public static void main(String[] args) is the entry point
2910
- const isJavaEntryPoint = lang === 'java' && name === 'main' &&
2911
- mods.includes('public') && mods.includes('static');
2912
-
2913
- // Python: Magic/dunder methods are called by the interpreter, not user code
2914
- // test_* functions/methods are called by pytest/unittest via reflection
2915
- // setUp/tearDown are unittest.TestCase framework methods called by test runner
2916
- // pytest_* are pytest plugin hooks called by the framework
2917
- const isPythonEntryPoint = lang === 'python' &&
2918
- (/^__\w+__$/.test(name) || /^test_/.test(name) ||
2919
- /^(setUp|tearDown)(Class|Module)?$/.test(name) ||
2920
- /^pytest_/.test(name));
2921
-
2922
- // Rust: main() is entry point, #[test] and #[bench] functions are called by test/bench runner
2923
- const isRustEntryPoint = lang === 'rust' &&
2924
- (name === 'main' || mods.includes('test') || mods.includes('bench'));
2925
-
2926
- // Rust: trait impl methods are invoked via trait dispatch, not direct calls
2927
- // They can never be "dead" - the trait contract requires them to exist
2928
- // className for trait impls contains " for " (e.g., "PartialEq for Glob")
2929
- const isRustTraitImpl = lang === 'rust' && symbol.isMethod &&
2930
- symbol.className && symbol.className.includes(' for ');
2931
-
2932
- // Go: Test*, Benchmark*, Example* functions are called by go test
2933
- const isGoTestFunc = lang === 'go' &&
2934
- /^(Test|Benchmark|Example)[A-Z]/.test(name);
2935
-
2936
- // Java: @Test annotated methods are called by JUnit
2937
- const isJavaTestMethod = lang === 'java' && mods.includes('test');
2938
-
2939
- // Java: @Override methods are invoked via polymorphic dispatch
2940
- // They implement interface/superclass contracts and can't be dead
2941
- const isJavaOverride = lang === 'java' && mods.includes('override');
2942
-
2943
- // Skip trait impl / @Override methods entirely - they're required by the type system
2944
- if (isRustTraitImpl || isJavaOverride) {
2945
- continue;
2946
- }
2947
-
2948
- // JavaScript/TypeScript: framework lifecycle methods called by runtime
2949
- // React class components, Web Components, Angular, Vue
2950
- const jsLifecycleMethods = new Set([
2951
- // React class component lifecycle
2952
- 'render', 'componentDidMount', 'componentDidUpdate', 'componentWillUnmount',
2953
- 'getDerivedStateFromProps', 'getDerivedStateFromError', 'componentDidCatch',
2954
- 'getSnapshotBeforeUpdate', 'shouldComponentUpdate',
2955
- // Web Components lifecycle
2956
- 'connectedCallback', 'disconnectedCallback', 'attributeChangedCallback', 'adoptedCallback'
2957
- ]);
2958
- const isJsEntryPoint = (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') &&
2959
- symbol.isMethod && jsLifecycleMethods.has(name);
2960
-
2961
- const isEntryPoint = isGoEntryPoint || isGoTestFunc ||
2962
- isJavaEntryPoint || isJavaTestMethod ||
2963
- isPythonEntryPoint || isRustEntryPoint || isJsEntryPoint;
2964
-
2965
- // Entry points are always excluded — they're invoked by the runtime, not user code
2966
- if (isEntryPoint) {
2967
- continue;
2968
- }
2969
-
2970
- // Framework registration decorators — excluded by default to reduce noise
2971
- // Python: decorators with '.' (attribute access) like @router.get, @app.route, @celery.task
2972
- // Java: non-standard annotations like @Bean, @Scheduled, @GetMapping
2973
- // These functions are invoked by frameworks, not by user code — AST can't see the call path
2974
- const javaKeywords = new Set(['public', 'private', 'protected', 'static', 'final', 'abstract', 'synchronized', 'native', 'default']);
2975
- const hasRegistrationDecorator = (() => {
2976
- if (lang === 'python') {
2977
- const decorators = symbol.decorators || [];
2978
- return decorators.some(d => d.includes('.'));
2979
- }
2980
- if (lang === 'java') {
2981
- return mods.some(m => !javaKeywords.has(m));
2982
- }
2983
- return false;
2984
- })();
2985
-
2986
- if (hasRegistrationDecorator && !options.includeDecorated) {
2987
- excludedDecorated++;
2988
- continue;
2989
- }
2990
-
2991
- const isExported = fileEntry && (
2992
- fileEntry.exports.includes(name) ||
2993
- mods.includes('export') ||
2994
- mods.includes('public') ||
2995
- (lang === 'go' && /^[A-Z]/.test(name))
2996
- );
2997
-
2998
- // Skip exported unless requested
2999
- if (isExported && !options.includeExported) {
3000
- excludedExported++;
3001
- continue;
3002
- }
3003
-
3004
- // Use pre-built index for O(1) lookup instead of O(files) scan
3005
- const allUsages = usageIndex.get(name) || [];
3006
-
3007
- // Filter out usages that are at the definition location
3008
- // nameLine: when decorators/annotations are present, startLine is the decorator line
3009
- // but the name identifier is on a different line (nameLine). Check both.
3010
- let nonDefUsages = allUsages.filter(u =>
3011
- !(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine))
3012
- );
3013
-
3014
- // For exported symbols in --include-exported mode, also filter out export-site
3015
- // references (e.g., `module.exports = { helperC }` or `export { helperC }`).
3016
- // These are just re-statements of the export, not actual consumption.
3017
- if (isExported && options.includeExported) {
3018
- nonDefUsages = nonDefUsages.filter(u => {
3019
- if (u.file !== symbol.file) return true; // cross-file usage always counts
3020
- // Check if same-file usage is on an export line
3021
- const content = this._readFile(u.file);
3022
- if (!content) return true;
3023
- const lines = content.split('\n');
3024
- const line = lines[u.line - 1] || '';
3025
- const trimmed = line.trim();
3026
- // CJS: module.exports = { ... } or exports.name = ...
3027
- if (trimmed.startsWith('module.exports') || /^exports\.\w+\s*=/.test(trimmed)) return false;
3028
- // ESM: export { ... } or export default
3029
- if (/^export\s*\{/.test(trimmed) || /^export\s+default\s/.test(trimmed)) return false;
3030
- return true;
3031
- });
3032
- }
3033
-
3034
- // Total includes all usage types (calls, references, callbacks, re-exports)
3035
- const totalUsages = nonDefUsages.length;
3036
-
3037
- if (totalUsages === 0) {
3038
- // Collect decorators/annotations for hint display
3039
- // Python: symbol.decorators (e.g., ['app.route("/path")', 'login_required'])
3040
- // Java/Rust/Go: symbol.modifiers may contain annotations (e.g., 'bean', 'scheduled')
3041
- const decorators = symbol.decorators || [];
3042
- // For Java, extract annotation-like modifiers (javaKeywords defined above)
3043
- const annotations = lang === 'java'
3044
- ? mods.filter(m => !javaKeywords.has(m))
3045
- : [];
3046
-
3047
- results.push({
3048
- name: symbol.name,
3049
- type: symbol.type,
3050
- file: symbol.relativePath,
3051
- startLine: symbol.startLine,
3052
- endLine: symbol.endLine,
3053
- isExported,
3054
- usageCount: 0,
3055
- ...(decorators.length > 0 && { decorators }),
3056
- ...(annotations.length > 0 && { annotations })
3057
- });
3058
- }
3059
- }
3060
- }
3061
-
3062
- // Sort by file then line
3063
- results.sort((a, b) => {
3064
- if (a.file !== b.file) return a.file.localeCompare(b.file);
3065
- return a.startLine - b.startLine;
3066
- });
3067
-
3068
- // Attach exclusion counts as array properties (backwards-compatible)
3069
- results.excludedDecorated = excludedDecorated;
3070
- results.excludedExported = excludedExported;
3071
-
3072
- return results;
3073
- } finally { this._endOp(); }
3074
- }
3075
-
3076
- /**
3077
- * Get dependency graph for a file
3078
- * @param {string} filePath - Starting file
3079
- * @param {object} options - { direction: 'imports' | 'importers' | 'both', maxDepth }
3080
- * @returns {object} - Graph structure with root, nodes, edges
3081
- */
3082
- graph(filePath, options = {}) {
3083
- const direction = options.direction || 'both';
3084
- // Sanitize depth: use default for null/undefined, clamp negative to 0
3085
- const rawDepth = options.maxDepth ?? 5;
3086
- const maxDepth = Math.max(0, rawDepth);
3087
-
3088
- const resolved = this.resolveFilePathForQuery(filePath);
3089
- if (typeof resolved !== 'string') return resolved;
3090
-
3091
- const targetPath = resolved;
3092
-
3093
- const buildSubgraph = (dir) => {
3094
- const visited = new Set();
3095
- const nodes = [];
3096
- const edges = [];
3097
-
3098
- const traverse = (file, depth) => {
3099
- if (visited.has(file)) return;
3100
- visited.add(file);
3101
-
3102
- const fileEntry = this.files.get(file);
3103
- const relPath = fileEntry ? fileEntry.relativePath : path.relative(this.root, file);
3104
- nodes.push({ file, relativePath: relPath, depth });
3105
-
3106
- // Stop traversal at max depth but still register the node above
3107
- if (depth >= maxDepth) return;
3108
-
3109
- let neighbors = [];
3110
- if (dir === 'imports') {
3111
- neighbors = this.importGraph.get(file) || [];
3112
- } else {
3113
- neighbors = this.exportGraph.get(file) || [];
3114
- }
3115
-
3116
- // Deduplicate neighbors (same file may be imported multiple times, e.g. Java inner classes)
3117
- const uniqueNeighbors = [...new Set(neighbors)];
3118
-
3119
- for (const neighbor of uniqueNeighbors) {
3120
- edges.push({ from: file, to: neighbor });
3121
- traverse(neighbor, depth + 1);
3122
- }
3123
- };
2125
+ };
3124
2126
 
3125
2127
  traverse(targetPath, 0);
3126
2128
  return { nodes, edges };
@@ -3257,7 +2259,7 @@ class ProjectIndex {
3257
2259
  const fileEntry = this.files.get(def.file);
3258
2260
  if (fileEntry) {
3259
2261
  for (const sym of fileEntry.symbols) {
3260
- if (sym.name !== name && sym.type === 'function') {
2262
+ if (sym.name !== name && !NON_CALLABLE_TYPES.has(sym.type)) {
3261
2263
  related.sameFile.push({
3262
2264
  name: sym.name,
3263
2265
  line: sym.startLine,
@@ -3586,737 +2588,43 @@ class ProjectIndex {
3586
2588
  } finally { this._endOp(); }
3587
2589
  }
3588
2590
 
3589
- /**
3590
- * Plan a refactoring operation
3591
- * @param {string} name - Function name
3592
- * @param {object} options - { addParam, removeParam, renameTo, defaultValue }
3593
- * @returns {object} Plan with before/after signatures and affected call sites
3594
- */
3595
- plan(name, options = {}) {
3596
- this._beginOp();
3597
- try {
3598
- const definitions = this.symbols.get(name);
3599
- if (!definitions || definitions.length === 0) {
3600
- return { found: false, function: name };
3601
- }
3602
-
3603
- const resolved = this.resolveSymbol(name, { file: options.file });
3604
- const def = resolved.def || definitions[0];
3605
- const impact = this.impact(name, { file: options.file });
3606
- const currentParams = def.paramsStructured || [];
3607
- const currentSignature = this.formatSignature(def);
3608
-
3609
- let newParams = [...currentParams];
3610
- let newSignature = currentSignature;
3611
- let operation = null;
3612
- let changes = [];
3613
-
3614
- if (options.addParam) {
3615
- operation = 'add-param';
3616
- const newParam = {
3617
- name: options.addParam,
3618
- ...(options.defaultValue && { default: options.defaultValue })
3619
- };
3620
- newParams.push(newParam);
3621
-
3622
- // Generate new signature
3623
- const paramsList = newParams.map(p => {
3624
- let str = p.name;
3625
- if (p.type) str += `: ${p.type}`;
3626
- if (p.default) str += ` = ${p.default}`;
3627
- return str;
3628
- }).join(', ');
3629
- newSignature = `${name}(${paramsList})`;
3630
- if (def.returnType) newSignature += `: ${def.returnType}`;
3631
-
3632
- // Describe changes needed at each call site
3633
- for (const fileGroup of impact.byFile) {
3634
- for (const site of fileGroup.sites) {
3635
- const suggestion = options.defaultValue
3636
- ? `No change needed (has default value)`
3637
- : `Add argument: ${options.addParam}`;
3638
- changes.push({
3639
- file: site.file,
3640
- line: site.line,
3641
- expression: site.expression,
3642
- suggestion,
3643
- args: site.args
3644
- });
3645
- }
3646
- }
3647
- }
3648
-
3649
- if (options.removeParam) {
3650
- operation = 'remove-param';
3651
- const paramIndex = currentParams.findIndex(p => p.name === options.removeParam);
3652
- if (paramIndex === -1) {
3653
- return {
3654
- found: true,
3655
- error: `Parameter "${options.removeParam}" not found in ${name}`,
3656
- currentParams: currentParams.map(p => p.name)
3657
- };
3658
- }
3659
-
3660
- newParams = currentParams.filter(p => p.name !== options.removeParam);
3661
-
3662
- // Generate new signature
3663
- const paramsList = newParams.map(p => {
3664
- let str = p.name;
3665
- if (p.type) str += `: ${p.type}`;
3666
- if (p.default) str += ` = ${p.default}`;
3667
- return str;
3668
- }).join(', ');
3669
- newSignature = `${name}(${paramsList})`;
3670
- if (def.returnType) newSignature += `: ${def.returnType}`;
3671
-
3672
- // For Python/Rust methods, self/cls/&self/&mut self is in paramsStructured
3673
- // but callers don't pass it. Adjust paramIndex to caller-side position.
3674
- const fileEntry = this.files.get(def.file);
3675
- const lang = fileEntry?.language;
3676
- let selfOffset = 0;
3677
- if ((lang === 'python' || lang === 'rust') && currentParams.length > 0) {
3678
- const firstName = currentParams[0].name;
3679
- if (firstName === 'self' || firstName === 'cls' || firstName === '&self' || firstName === '&mut self') {
3680
- selfOffset = 1;
3681
- }
3682
- }
3683
- const callerArgIndex = paramIndex - selfOffset;
3684
-
3685
- // Describe changes at each call site
3686
- for (const fileGroup of impact.byFile) {
3687
- for (const site of fileGroup.sites) {
3688
- if (site.args && site.argCount > callerArgIndex) {
3689
- changes.push({
3690
- file: site.file,
3691
- line: site.line,
3692
- expression: site.expression,
3693
- suggestion: `Remove argument ${callerArgIndex + 1}: ${site.args[callerArgIndex] || '?'}`,
3694
- args: site.args
3695
- });
3696
- }
3697
- }
3698
- }
3699
- }
3700
-
3701
- if (options.renameTo) {
3702
- operation = 'rename';
3703
- newSignature = currentSignature.replace(name, options.renameTo);
3704
-
3705
- // All call sites need renaming
3706
- for (const fileGroup of impact.byFile) {
3707
- for (const site of fileGroup.sites) {
3708
- const newExpression = site.expression.replace(
3709
- new RegExp('\\b' + escapeRegExp(name) + '\\b'),
3710
- options.renameTo
3711
- );
3712
- changes.push({
3713
- file: site.file,
3714
- line: site.line,
3715
- expression: site.expression,
3716
- suggestion: `Rename to: ${newExpression}`,
3717
- newExpression
3718
- });
3719
- }
3720
- }
3721
- }
3722
-
3723
- return {
3724
- found: true,
3725
- function: name,
3726
- file: def.relativePath,
3727
- startLine: def.startLine,
3728
- operation,
3729
- before: {
3730
- signature: currentSignature,
3731
- params: currentParams.map(p => p.name)
3732
- },
3733
- after: {
3734
- signature: newSignature,
3735
- params: newParams.map(p => p.name)
3736
- },
3737
- totalChanges: changes.length,
3738
- filesAffected: new Set(changes.map(c => c.file)).size,
3739
- changes
3740
- };
3741
- } finally { this._endOp(); }
3742
- }
2591
+ /** Plan a refactoring operation */
2592
+ plan(name, options) { return verifyModule.plan(this, name, options); }
3743
2593
 
3744
- /**
3745
- * Parse a stack trace and show code for each frame
3746
- * @param {string} stackText - Stack trace text
3747
- * @returns {object} Parsed frames with code context
3748
- */
2594
+ /** Parse a stack trace and show code for each frame */
3749
2595
  parseStackTrace(stackText) {
3750
- const frames = [];
3751
- const lines = stackText.split(/\\n|\n/);
3752
-
3753
- // Stack trace patterns for different languages/runtimes
3754
- // Order matters - more specific patterns first
3755
- const patterns = [
3756
- // JavaScript Node.js: "at functionName (file.js:line:col)" or "at file.js:line:col"
3757
- { regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?([^():]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
3758
- // Deno: "at functionName (file:///path/to/file.ts:line:col)"
3759
- { regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?file:\/\/([^:]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
3760
- // Bun: "at functionName (file.js:line:col)" - similar to Node but may have different formatting
3761
- { regex: /^\s+at\s+(.+?)\s+\[as\s+\w+\]\s+\(([^:]+):(\d+):(\d+)\)/, extract: (m) => ({ funcName: m[1], file: m[2], line: parseInt(m[3]), col: parseInt(m[4]) }) },
3762
- // Browser Chrome/V8: "at functionName (http://... or file:// ...)"
3763
- { regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?(?:https?:\/\/[^/]+)?([^():]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
3764
- // Firefox: "functionName@file:line:col"
3765
- { regex: /^(.+)@(.+):(\d+):(\d+)$/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: parseInt(m[4]) }) },
3766
- // Safari: "functionName@file:line:col" (similar to Firefox)
3767
- { regex: /^(.+)@(?:https?:\/\/[^/]+)?([^:]+):(\d+)(?::(\d+))?$/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
3768
- // Python: "File \"file.py\", line N, in function"
3769
- { regex: /File\s+"([^"]+)",\s+line\s+(\d+)(?:,\s+in\s+(.+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), funcName: m[3] || null, col: null }) },
3770
- // Go: "file.go:line" or "package/file.go:line +0x..."
3771
- { regex: /^\s*([^\s:]+\.go):(\d+)(?:\s|$)/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), funcName: null, col: null }) },
3772
- // Go with function: "package.FunctionName()\n\tfile.go:line"
3773
- { regex: /^\s*([^\s(]+)\(\)$/, extract: null }, // Skip function-only lines
3774
- // Java: "at package.Class.method(File.java:line)"
3775
- { regex: /at\s+([^\(]+)\(([^:]+):(\d+)\)/, extract: (m) => ({ funcName: m[1].split('.').pop(), file: m[2], line: parseInt(m[3]), col: null }) },
3776
- // Rust: "at src/main.rs:line:col" or panic location
3777
- { regex: /(?:at\s+)?([^\s:]+\.rs):(\d+)(?::(\d+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), col: m[3] ? parseInt(m[3]) : null, funcName: null }) },
3778
- // Generic: "file:line" as last resort
3779
- { regex: /([^\s:]+\.\w+):(\d+)(?::(\d+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), col: m[3] ? parseInt(m[3]) : null, funcName: null }) }
3780
- ];
3781
-
3782
- for (const line of lines) {
3783
- const trimmed = line.trim();
3784
- if (!trimmed) continue;
3785
-
3786
- // Try each pattern until one matches
3787
- for (const pattern of patterns) {
3788
- const match = pattern.regex.exec(trimmed);
3789
- if (match && pattern.extract) {
3790
- const extracted = pattern.extract(match);
3791
- if (extracted && extracted.file && extracted.line) {
3792
- frames.push(this.createStackFrame(
3793
- extracted.file,
3794
- extracted.line,
3795
- extracted.funcName,
3796
- extracted.col,
3797
- trimmed
3798
- ));
3799
- break; // Move to next line
3800
- }
3801
- }
3802
- }
3803
- }
3804
-
3805
- return {
3806
- frameCount: frames.length,
3807
- frames
3808
- };
2596
+ return stacktrace.parseStackTrace(this, stackText);
3809
2597
  }
3810
2598
 
3811
- /**
3812
- * Calculate path similarity score between two file paths
3813
- * Higher score = better match
3814
- * @param {string} query - The path from stack trace
3815
- * @param {string} candidate - The candidate file path
3816
- * @returns {number} Similarity score
3817
- */
2599
+ /** Calculate path similarity score between two file paths */
3818
2600
  calculatePathSimilarity(query, candidate) {
3819
- // Normalize paths for comparison
3820
- const queryParts = query.replace(/\\/g, '/').split('/').filter(Boolean);
3821
- const candidateParts = candidate.replace(/\\/g, '/').split('/').filter(Boolean);
3822
-
3823
- let score = 0;
3824
-
3825
- // Exact match on full path
3826
- if (candidate.endsWith(query)) {
3827
- score += 100;
3828
- }
3829
-
3830
- // Compare from the end (most important part)
3831
- let matches = 0;
3832
- const minLen = Math.min(queryParts.length, candidateParts.length);
3833
- for (let i = 0; i < minLen; i++) {
3834
- const queryPart = queryParts[queryParts.length - 1 - i];
3835
- const candPart = candidateParts[candidateParts.length - 1 - i];
3836
- if (queryPart === candPart) {
3837
- matches++;
3838
- // Earlier parts (closer to filename) score more
3839
- score += (10 - i) * 5;
3840
- } else {
3841
- break; // Stop at first mismatch
3842
- }
3843
- }
3844
-
3845
- // Bonus for matching most of the query path
3846
- if (matches === queryParts.length) {
3847
- score += 50;
3848
- }
3849
-
3850
- // Filename match is essential
3851
- const queryFile = queryParts[queryParts.length - 1];
3852
- const candFile = candidateParts[candidateParts.length - 1];
3853
- if (queryFile !== candFile) {
3854
- score = 0; // No match if filename doesn't match
3855
- }
3856
-
3857
- return score;
2601
+ return stacktrace.calculatePathSimilarity(query, candidate);
3858
2602
  }
3859
2603
 
3860
- /**
3861
- * Find the best matching file for a stack trace path
3862
- * @param {string} filePath - Path from stack trace
3863
- * @param {string|null} funcName - Function name for verification
3864
- * @param {number} lineNum - Line number for verification
3865
- * @returns {{path: string, relativePath: string, confidence: number}|null}
3866
- */
2604
+ /** Find the best matching file for a stack trace path */
3867
2605
  findBestMatchingFile(filePath, funcName, lineNum) {
3868
- const candidates = [];
3869
-
3870
- // Collect all potential matches with scores
3871
- for (const [absPath, fileEntry] of this.files) {
3872
- const score = this.calculatePathSimilarity(filePath, absPath);
3873
- const relScore = this.calculatePathSimilarity(filePath, fileEntry.relativePath);
3874
- const bestScore = Math.max(score, relScore);
3875
-
3876
- if (bestScore > 0) {
3877
- candidates.push({
3878
- absPath,
3879
- relativePath: fileEntry.relativePath,
3880
- score: bestScore,
3881
- fileEntry
3882
- });
3883
- }
3884
- }
3885
-
3886
- if (candidates.length === 0) {
3887
- // Try absolute path
3888
- const absPath = path.isAbsolute(filePath) ? filePath : path.join(this.root, filePath);
3889
- if (fs.existsSync(absPath)) {
3890
- return {
3891
- path: absPath,
3892
- relativePath: path.relative(this.root, absPath),
3893
- confidence: 0.5 // Low confidence for unindexed files
3894
- };
3895
- }
3896
- return null;
3897
- }
3898
-
3899
- // Sort by score descending
3900
- candidates.sort((a, b) => b.score - a.score);
3901
-
3902
- // If there's a function name, verify it exists at the line
3903
- if (funcName && candidates.length > 1) {
3904
- for (const cand of candidates) {
3905
- const symbols = this.symbols.get(funcName);
3906
- if (symbols) {
3907
- const match = symbols.find(s =>
3908
- s.file === cand.absPath &&
3909
- s.startLine <= lineNum && s.endLine >= lineNum
3910
- );
3911
- if (match) {
3912
- // This candidate has the function at the right line - strong match
3913
- return {
3914
- path: cand.absPath,
3915
- relativePath: cand.relativePath,
3916
- confidence: 1.0,
3917
- verifiedFunction: true
3918
- };
3919
- }
3920
- }
3921
- }
3922
- }
3923
-
3924
- // Return best scoring candidate
3925
- const best = candidates[0];
3926
- const confidence = candidates.length === 1 ? 0.9 :
3927
- (best.score > 100 ? 0.8 : 0.6);
3928
-
3929
- return {
3930
- path: best.absPath,
3931
- relativePath: best.relativePath,
3932
- confidence
3933
- };
2606
+ return stacktrace.findBestMatchingFile(this, filePath, funcName, lineNum);
3934
2607
  }
3935
2608
 
3936
- /**
3937
- * Create a stack frame with code context
3938
- */
2609
+ /** Create a stack frame with code context */
3939
2610
  createStackFrame(filePath, lineNum, funcName, col, rawLine) {
3940
- const frame = {
3941
- file: filePath,
3942
- line: lineNum,
3943
- function: funcName,
3944
- column: col,
3945
- raw: rawLine,
3946
- found: false,
3947
- code: null,
3948
- context: null,
3949
- confidence: 0
3950
- };
3951
-
3952
- // Find the best matching file using improved algorithm
3953
- const match = this.findBestMatchingFile(filePath, funcName, lineNum);
3954
-
3955
- if (match) {
3956
- const resolvedPath = match.path;
3957
- frame.found = true;
3958
- frame.resolvedFile = match.relativePath;
3959
- frame.confidence = match.confidence;
3960
- if (match.verifiedFunction) {
3961
- frame.verifiedFunction = true;
3962
- }
3963
-
3964
- try {
3965
- const content = this._readFile(resolvedPath);
3966
- const lines = content.split('\n');
3967
-
3968
- // Get the exact line
3969
- if (lineNum > 0 && lineNum <= lines.length) {
3970
- frame.code = lines[lineNum - 1];
3971
-
3972
- // Get context (2 lines before, 2 after)
3973
- const contextLines = [];
3974
- for (let i = Math.max(0, lineNum - 3); i < Math.min(lines.length, lineNum + 2); i++) {
3975
- contextLines.push({
3976
- line: i + 1,
3977
- code: lines[i],
3978
- isCurrent: i + 1 === lineNum
3979
- });
3980
- }
3981
- frame.context = contextLines;
3982
- }
3983
-
3984
- // Try to find function info (verify it contains the line)
3985
- if (funcName) {
3986
- const symbols = this.symbols.get(funcName);
3987
- if (symbols) {
3988
- const funcMatch = symbols.find(s =>
3989
- s.file === resolvedPath &&
3990
- s.startLine <= lineNum && s.endLine >= lineNum
3991
- );
3992
- if (funcMatch) {
3993
- frame.functionInfo = {
3994
- name: funcMatch.name,
3995
- startLine: funcMatch.startLine,
3996
- endLine: funcMatch.endLine,
3997
- params: funcMatch.params
3998
- };
3999
- frame.confidence = 1.0; // High confidence when function verified
4000
- } else {
4001
- // Function exists but line doesn't match - lower confidence
4002
- const anyMatch = symbols.find(s => s.file === resolvedPath);
4003
- if (anyMatch) {
4004
- frame.functionInfo = {
4005
- name: anyMatch.name,
4006
- startLine: anyMatch.startLine,
4007
- endLine: anyMatch.endLine,
4008
- params: anyMatch.params,
4009
- lineMismatch: true
4010
- };
4011
- frame.confidence = Math.min(frame.confidence, 0.5);
4012
- }
4013
- }
4014
- }
4015
- } else {
4016
- // No function name in stack - find enclosing function
4017
- const enclosing = this.findEnclosingFunction(resolvedPath, lineNum, true);
4018
- if (enclosing) {
4019
- frame.functionInfo = {
4020
- name: enclosing.name,
4021
- startLine: enclosing.startLine,
4022
- endLine: enclosing.endLine,
4023
- params: enclosing.params,
4024
- inferred: true
4025
- };
4026
- }
4027
- }
4028
- } catch (e) {
4029
- frame.error = e.message;
4030
- }
4031
- }
4032
-
4033
- return frame;
4034
- }
4035
-
4036
- /**
4037
- * Verify that all call sites match a function's signature
4038
- * @param {string} name - Function name
4039
- * @returns {object} Verification results with mismatches
4040
- */
4041
- verify(name, options = {}) {
4042
- this._beginOp();
4043
- try {
4044
- const { def } = this.resolveSymbol(name, { file: options.file });
4045
- if (!def) {
4046
- return { found: false, function: name };
4047
- }
4048
- // For Python/Rust methods, exclude self/cls from parameter count
4049
- // (callers don't pass self/cls explicitly: obj.method(a, b) not obj.method(obj, a, b))
4050
- const fileEntry = this.files.get(def.file);
4051
- const lang = fileEntry?.language;
4052
- let params = def.paramsStructured || [];
4053
- if ((lang === 'python' || lang === 'rust') && params.length > 0) {
4054
- const firstName = params[0].name;
4055
- if (firstName === 'self' || firstName === 'cls' || firstName === '&self' || firstName === '&mut self') {
4056
- params = params.slice(1);
4057
- }
4058
- }
4059
- const hasRest = params.some(p => p.rest);
4060
- // Rest params don't count toward expected/min — they accept 0+ extra args
4061
- const nonRestParams = params.filter(p => !p.rest);
4062
- const expectedParamCount = nonRestParams.length;
4063
- const optionalCount = nonRestParams.filter(p => p.optional || p.default !== undefined).length;
4064
- const minArgs = expectedParamCount - optionalCount;
4065
-
4066
- // Get all call sites
4067
- const usages = this.usages(name, { codeOnly: true });
4068
- const calls = usages.filter(u => u.usageType === 'call' && !u.isDefinition);
4069
-
4070
- const valid = [];
4071
- const mismatches = [];
4072
- const uncertain = [];
4073
-
4074
- // If the definition is NOT a method, filter out method calls (e.g., dict.get() vs get())
4075
- // This prevents false positives where a standalone function name matches method calls
4076
- const defIsMethod = def.isMethod || def.type === 'method' || def.className;
4077
-
4078
- for (const call of calls) {
4079
- const analysis = this.analyzeCallSite(call, name);
4080
-
4081
- // Skip method calls when verifying a non-method definition
4082
- if (analysis.isMethodCall && !defIsMethod) {
4083
- continue;
4084
- }
4085
-
4086
- if (analysis.args === null) {
4087
- // Couldn't parse arguments
4088
- uncertain.push({
4089
- file: call.relativePath,
4090
- line: call.line,
4091
- expression: call.content.trim(),
4092
- reason: 'Could not parse call arguments'
4093
- });
4094
- continue;
4095
- }
4096
-
4097
- if (analysis.hasSpread) {
4098
- // Spread args - can't verify count
4099
- uncertain.push({
4100
- file: call.relativePath,
4101
- line: call.line,
4102
- expression: call.content.trim(),
4103
- reason: 'Uses spread operator'
4104
- });
4105
- continue;
4106
- }
4107
-
4108
- const argCount = analysis.argCount;
4109
-
4110
- // Check if arg count is valid
4111
- if (hasRest) {
4112
- // With rest param, need at least minArgs
4113
- if (argCount >= minArgs) {
4114
- valid.push({ file: call.relativePath, line: call.line });
4115
- } else {
4116
- mismatches.push({
4117
- file: call.relativePath,
4118
- line: call.line,
4119
- expression: call.content.trim(),
4120
- expected: `at least ${minArgs} arg(s)`,
4121
- actual: argCount,
4122
- args: analysis.args
4123
- });
4124
- }
4125
- } else {
4126
- // Without rest, need between minArgs and expectedParamCount
4127
- if (argCount >= minArgs && argCount <= expectedParamCount) {
4128
- valid.push({ file: call.relativePath, line: call.line });
4129
- } else {
4130
- mismatches.push({
4131
- file: call.relativePath,
4132
- line: call.line,
4133
- expression: call.content.trim(),
4134
- expected: minArgs === expectedParamCount
4135
- ? `${expectedParamCount} arg(s)`
4136
- : `${minArgs}-${expectedParamCount} arg(s)`,
4137
- actual: argCount,
4138
- args: analysis.args
4139
- });
4140
- }
4141
- }
4142
- }
4143
- this._clearTreeCache();
4144
-
4145
- return {
4146
- found: true,
4147
- function: name,
4148
- file: def.relativePath,
4149
- startLine: def.startLine,
4150
- signature: this.formatSignature(def),
4151
- params: params.map(p => ({
4152
- name: p.name,
4153
- optional: p.optional || p.default !== undefined,
4154
- hasDefault: p.default !== undefined
4155
- })),
4156
- expectedArgs: { min: minArgs, max: hasRest ? '∞' : expectedParamCount },
4157
- totalCalls: valid.length + mismatches.length + uncertain.length,
4158
- valid: valid.length,
4159
- mismatches: mismatches.length,
4160
- uncertain: uncertain.length,
4161
- mismatchDetails: mismatches,
4162
- uncertainDetails: uncertain
4163
- };
4164
- } finally { this._endOp(); }
2611
+ return stacktrace.createStackFrame(this, filePath, lineNum, funcName, col, rawLine);
4165
2612
  }
4166
2613
 
4167
- /**
4168
- * Analyze a call site to understand how it's being called (AST-based)
4169
- * @param {object} call - Usage object with file, line, content
4170
- * @param {string} funcName - Function name to find
4171
- * @returns {object} { args, argCount, hasSpread, hasVariable }
4172
- */
4173
- analyzeCallSite(call, funcName) {
4174
- try {
4175
- const language = detectLanguage(call.file);
4176
- if (!language) return { args: null, argCount: 0 };
4177
-
4178
- // Use tree cache to avoid re-parsing the same file in batch operations
4179
- let tree = this._treeCache?.get(call.file);
4180
- if (!tree) {
4181
- const content = this._readFile(call.file);
4182
- // HTML files need special handling: parse script blocks as JS
4183
- if (language === 'html') {
4184
- const htmlModule = getLanguageModule('html');
4185
- const htmlParser = getParser('html');
4186
- const jsParser = getParser('javascript');
4187
- if (!htmlParser || !jsParser) return { args: null, argCount: 0 };
4188
- const blocks = htmlModule.extractScriptBlocks(content, htmlParser);
4189
- if (blocks.length === 0) return { args: null, argCount: 0 };
4190
- const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
4191
- tree = safeParse(jsParser, virtualJS);
4192
- } else {
4193
- const parser = getParser(language);
4194
- if (!parser) return { args: null, argCount: 0 };
4195
- tree = safeParse(parser, content);
4196
- }
4197
- if (!tree) return { args: null, argCount: 0 };
4198
- if (!this._treeCache) this._treeCache = new Map();
4199
- this._treeCache.set(call.file, tree);
4200
- }
4201
-
4202
- // Call node types vary by language
4203
- const callTypes = new Set(['call_expression', 'call', 'method_invocation', 'object_creation_expression']);
4204
- const targetRow = call.line - 1; // tree-sitter is 0-indexed
4205
-
4206
- // Find the call expression at the target line matching funcName
4207
- const callNode = this._findCallNode(tree.rootNode, callTypes, targetRow, funcName);
4208
- if (!callNode) return { args: null, argCount: 0 };
4209
-
4210
- // Check if this is a method call (obj.func()) vs a direct call (func())
4211
- const funcNode = callNode.childForFieldName('function') ||
4212
- callNode.childForFieldName('name');
4213
- let isMethodCall = false;
4214
- if (funcNode) {
4215
- // member_expression (JS), attribute (Python), selector_expression (Go), field_expression (Rust)
4216
- if (['member_expression', 'attribute', 'selector_expression', 'field_expression'].includes(funcNode.type)) {
4217
- isMethodCall = true;
4218
- }
4219
- // Java method_invocation with object
4220
- if (callNode.type === 'method_invocation' && callNode.childForFieldName('object')) {
4221
- isMethodCall = true;
4222
- }
4223
- }
4224
-
4225
- const argsNode = callNode.childForFieldName('arguments');
4226
- if (!argsNode) return { args: [], argCount: 0, isMethodCall };
2614
+ /** Verify that all call sites match a function's signature */
2615
+ verify(name, options) { return verifyModule.verify(this, name, options); }
4227
2616
 
4228
- const args = [];
4229
- for (let i = 0; i < argsNode.namedChildCount; i++) {
4230
- args.push(argsNode.namedChild(i).text.trim());
4231
- }
4232
-
4233
- return {
4234
- args,
4235
- argCount: args.length,
4236
- hasSpread: args.some(a => a.startsWith('...')),
4237
- hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a)),
4238
- isMethodCall
4239
- };
4240
- } catch (e) {
4241
- return { args: null, argCount: 0 };
4242
- }
4243
- }
2617
+ /** Analyze a call site to understand how it's being called (AST-based) */
2618
+ analyzeCallSite(call, funcName) { return verifyModule.analyzeCallSite(this, call, funcName); }
4244
2619
 
4245
- /**
4246
- * Find a call expression node at the target line matching funcName
4247
- */
4248
- _findCallNode(node, callTypes, targetRow, funcName) {
4249
- if (node.startPosition.row > targetRow || node.endPosition.row < targetRow) {
4250
- return null; // Skip nodes that don't contain the target line
4251
- }
4252
-
4253
- if (callTypes.has(node.type) && node.startPosition.row === targetRow) {
4254
- // Java constructor: new ClassName(args) — name is in 'type' field
4255
- if (node.type === 'object_creation_expression') {
4256
- const typeNode = node.childForFieldName('type');
4257
- if (typeNode) {
4258
- // Strip generics and package qualifiers: com.foo.Bar<T> -> Bar
4259
- const typeName = typeNode.text.replace(/<.*>$/, '').split('.').pop();
4260
- if (typeName === funcName) return node;
4261
- }
4262
- } else {
4263
- // Check if this call is for our target function
4264
- const funcNode = node.childForFieldName('function') ||
4265
- node.childForFieldName('name'); // Java method_invocation uses 'name'
4266
- if (funcNode) {
4267
- const funcText = funcNode.type === 'member_expression' || funcNode.type === 'selector_expression' || funcNode.type === 'field_expression' || funcNode.type === 'attribute'
4268
- ? (funcNode.childForFieldName('property') || funcNode.childForFieldName('field') || funcNode.childForFieldName('attribute') || funcNode.namedChild(funcNode.namedChildCount - 1))?.text
4269
- : funcNode.text;
4270
- if (funcText === funcName) return node;
4271
- }
4272
- }
4273
- }
2620
+ /** Find a call expression node at the target line matching funcName */
2621
+ _findCallNode(node, callTypes, targetRow, funcName) { return verifyModule.findCallNode(node, callTypes, targetRow, funcName); }
4274
2622
 
4275
- // Recurse into children
4276
- for (let i = 0; i < node.childCount; i++) {
4277
- const result = this._findCallNode(node.child(i), callTypes, targetRow, funcName);
4278
- if (result) return result;
4279
- }
4280
- return null;
4281
- }
2623
+ /** Clear the AST tree cache (call after batch operations) */
2624
+ _clearTreeCache() { verifyModule.clearTreeCache(this); }
4282
2625
 
4283
- /**
4284
- * Clear the AST tree cache (call after batch operations)
4285
- */
4286
- _clearTreeCache() {
4287
- this._treeCache = null;
4288
- }
4289
-
4290
- /**
4291
- * Identify common calling patterns
4292
- */
4293
- identifyCallPatterns(callSites, funcName) {
4294
- const patterns = {
4295
- constantArgs: 0, // Call sites with literal/constant arguments
4296
- variableArgs: 0, // Call sites passing variables
4297
- chainedCalls: 0, // Calls that are part of method chains
4298
- awaitedCalls: 0, // Async calls with await
4299
- spreadCalls: 0 // Calls using spread operator
4300
- };
4301
-
4302
- for (const site of callSites) {
4303
- const expr = site.expression;
4304
-
4305
- if (site.hasSpread) patterns.spreadCalls++;
4306
- if (/await\s/.test(expr)) patterns.awaitedCalls++;
4307
- if (new RegExp('\\.' + escapeRegExp(funcName) + '\\s*\\(').test(expr)) patterns.chainedCalls++;
4308
-
4309
- if (site.args && site.args.length > 0) {
4310
- const hasLiteral = site.args.some(a =>
4311
- /^[\d'"{\[]/.test(a) || a === 'true' || a === 'false' || a === 'null'
4312
- );
4313
- if (hasLiteral) patterns.constantArgs++;
4314
- if (site.hasVariable) patterns.variableArgs++;
4315
- }
4316
- }
4317
-
4318
- return patterns;
4319
- }
2626
+ /** Identify common calling patterns */
2627
+ identifyCallPatterns(callSites, funcName) { return verifyModule.identifyCallPatterns(callSites, funcName); }
4320
2628
 
4321
2629
  /**
4322
2630
  * Get complete information about a symbol - definition, usages, callers, callees, tests, code
@@ -4333,10 +2641,10 @@ class ProjectIndex {
4333
2641
  const maxCallees = options.all ? Infinity : (options.maxCallees || 10);
4334
2642
  const includeMethods = options.includeMethods ?? true;
4335
2643
 
4336
- // Find symbol definition(s)
4337
- const definitions = this.find(name, { exact: true, file: options.file });
2644
+ // Find symbol definition(s) — skip counts since about() computes its own via usages()
2645
+ const definitions = this.find(name, { exact: true, file: options.file, skipCounts: true });
4338
2646
  if (definitions.length === 0) {
4339
- // Try fuzzy match
2647
+ // Try fuzzy match (needs counts for suggestion ranking)
4340
2648
  const fuzzy = this.find(name, { file: options.file });
4341
2649
  if (fuzzy.length === 0) {
4342
2650
  return null;
@@ -4379,7 +2687,7 @@ class ProjectIndex {
4379
2687
  let allCallers = null;
4380
2688
  let allCallees = null;
4381
2689
  if (primary.type === 'function' || primary.params !== undefined) {
4382
- allCallers = this.findCallers(symbolName, { includeMethods });
2690
+ allCallers = this.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary] });
4383
2691
  // Apply exclude filter before slicing
4384
2692
  if (options.exclude && options.exclude.length > 0) {
4385
2693
  allCallers = allCallers.filter(c => this.matchesFilters(c.relativePath, { exclude: options.exclude }));
@@ -4470,7 +2778,7 @@ class ProjectIndex {
4470
2778
  otherDefinitions: (options.all ? others : others.slice(0, 3)).map(d => ({
4471
2779
  file: d.relativePath,
4472
2780
  line: d.startLine,
4473
- usageCount: d.usageCount
2781
+ usageCount: d.usageCount ?? this.countSymbolUsages(d).total
4474
2782
  })),
4475
2783
  types,
4476
2784
  code,
@@ -4662,7 +2970,8 @@ class ProjectIndex {
4662
2970
  const functions = [];
4663
2971
  for (const [name, symbols] of this.symbols) {
4664
2972
  for (const sym of symbols) {
4665
- if (sym.type === 'function' || sym.params !== undefined) {
2973
+ if (sym.type === 'function' || sym.type === 'method' || sym.type === 'static' ||
2974
+ sym.type === 'constructor' || sym.type === 'public' || sym.type === 'abstract') {
4666
2975
  const lineCount = sym.endLine - sym.startLine + 1;
4667
2976
  const relativePath = sym.relativePath || (sym.file ? path.relative(this.root, sym.file) : '');
4668
2977
  functions.push({
@@ -4695,7 +3004,8 @@ class ProjectIndex {
4695
3004
 
4696
3005
  for (const [filePath, fileEntry] of this.files) {
4697
3006
  let functions = fileEntry.symbols.filter(s =>
4698
- s.type === 'function' || s.type === 'method' || s.type === 'static' || s.type === 'constructor'
3007
+ s.type === 'function' || s.type === 'method' || s.type === 'static' ||
3008
+ s.type === 'constructor' || s.type === 'public' || s.type === 'abstract'
4699
3009
  );
4700
3010
  const classes = fileEntry.symbols.filter(s =>
4701
3011
  ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'impl'].includes(s.type)
@@ -4802,182 +3112,14 @@ class ProjectIndex {
4802
3112
  // CACHE METHODS
4803
3113
  // ========================================================================
4804
3114
 
4805
- /**
4806
- * Save index to cache file
4807
- *
4808
- * @param {string} [cachePath] - Optional custom cache path
4809
- * @returns {string} - Path to cache file
4810
- */
4811
- saveCache(cachePath) {
4812
- const cacheDir = cachePath
4813
- ? path.dirname(cachePath)
4814
- : path.join(this.root, '.ucn-cache');
4815
-
4816
- if (!fs.existsSync(cacheDir)) {
4817
- fs.mkdirSync(cacheDir, { recursive: true });
4818
- }
4819
-
4820
- const cacheFile = cachePath || path.join(cacheDir, 'index.json');
4821
-
4822
- // Prepare callsCache for serialization (exclude content to save space)
4823
- const callsCacheData = [];
4824
- for (const [filePath, entry] of this.callsCache) {
4825
- callsCacheData.push([filePath, {
4826
- mtime: entry.mtime,
4827
- hash: entry.hash,
4828
- calls: entry.calls
4829
- // content is not persisted - will be read on demand
4830
- }]);
4831
- }
4832
-
4833
- const cacheData = {
4834
- version: 4, // v4: className, memberType, isMethod for all languages
4835
- ucnVersion: UCN_VERSION, // Invalidate cache when UCN is updated
4836
- root: this.root,
4837
- buildTime: this.buildTime,
4838
- timestamp: Date.now(),
4839
- files: Array.from(this.files.entries()),
4840
- symbols: Array.from(this.symbols.entries()),
4841
- importGraph: Array.from(this.importGraph.entries()),
4842
- exportGraph: Array.from(this.exportGraph.entries()),
4843
- extendsGraph: Array.from(this.extendsGraph.entries()),
4844
- extendedByGraph: Array.from(this.extendedByGraph.entries()),
4845
- callsCache: callsCacheData,
4846
- failedFiles: this.failedFiles ? Array.from(this.failedFiles) : []
4847
- };
4848
-
4849
- fs.writeFileSync(cacheFile, JSON.stringify(cacheData));
4850
- return cacheFile;
4851
- }
4852
-
4853
- /**
4854
- * Load index from cache file
4855
- *
4856
- * @param {string} [cachePath] - Optional custom cache path
4857
- * @returns {boolean} - True if loaded successfully
4858
- */
4859
- loadCache(cachePath) {
4860
- const cacheFile = cachePath || path.join(this.root, '.ucn-cache', 'index.json');
4861
-
4862
- if (!fs.existsSync(cacheFile)) {
4863
- return false;
4864
- }
4865
-
4866
- try {
4867
- const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
4868
-
4869
- // Check version compatibility
4870
- // v4 adds className, memberType, isMethod for all languages
4871
- // Only accept exactly version 4 (or future versions handled explicitly)
4872
- if (cacheData.version !== 4) {
4873
- return false;
4874
- }
4875
-
4876
- // Invalidate cache when UCN version changes (logic may have changed)
4877
- if (cacheData.ucnVersion !== UCN_VERSION) {
4878
- return false;
4879
- }
4880
-
4881
- // Validate cache structure has required fields
4882
- if (!Array.isArray(cacheData.files) ||
4883
- !Array.isArray(cacheData.symbols) ||
4884
- !Array.isArray(cacheData.importGraph) ||
4885
- !Array.isArray(cacheData.exportGraph)) {
4886
- return false;
4887
- }
4888
-
4889
- this.files = new Map(cacheData.files);
4890
- this.symbols = new Map(cacheData.symbols);
4891
- this.importGraph = new Map(cacheData.importGraph);
4892
- this.exportGraph = new Map(cacheData.exportGraph);
4893
- this.buildTime = cacheData.buildTime;
4894
-
4895
- // Restore optional graphs if present
4896
- if (Array.isArray(cacheData.extendsGraph)) {
4897
- this.extendsGraph = new Map(cacheData.extendsGraph);
4898
- }
4899
- if (Array.isArray(cacheData.extendedByGraph)) {
4900
- this.extendedByGraph = new Map(cacheData.extendedByGraph);
4901
- }
4902
-
4903
- // Restore callsCache if present (v2+)
4904
- if (Array.isArray(cacheData.callsCache)) {
4905
- this.callsCache = new Map(cacheData.callsCache);
4906
- }
3115
+ /** Save index to cache file */
3116
+ saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
4907
3117
 
4908
- // Restore failedFiles if present
4909
- if (Array.isArray(cacheData.failedFiles)) {
4910
- this.failedFiles = new Set(cacheData.failedFiles);
4911
- }
4912
-
4913
- // Rebuild derived graphs to ensure consistency with current config
4914
- this.buildImportGraph();
4915
- this.buildInheritanceGraph();
3118
+ /** Load index from cache file */
3119
+ loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
4916
3120
 
4917
- return true;
4918
- } catch (e) {
4919
- return false;
4920
- }
4921
- }
4922
-
4923
- /**
4924
- * Check if cache is stale (any files changed or new files added)
4925
- *
4926
- * @returns {boolean} - True if cache needs rebuilding
4927
- */
4928
- isCacheStale() {
4929
- // Check for new files added to project
4930
- // Use same ignores as build() — .gitignore + .ucn.json exclude
4931
- const pattern = detectProjectPattern(this.root);
4932
- const globOpts = { root: this.root };
4933
- const gitignorePatterns = parseGitignore(this.root);
4934
- const configExclude = this.config.exclude || [];
4935
- if (gitignorePatterns.length > 0 || configExclude.length > 0) {
4936
- globOpts.ignores = [...DEFAULT_IGNORES, ...gitignorePatterns, ...configExclude];
4937
- }
4938
- const currentFiles = expandGlob(pattern, globOpts);
4939
- const cachedPaths = new Set(this.files.keys());
4940
-
4941
- for (const file of currentFiles) {
4942
- if (!cachedPaths.has(file) && !(this.failedFiles && this.failedFiles.has(file))) {
4943
- return true; // New file found
4944
- }
4945
- }
4946
-
4947
- // Check existing cached files for modifications/deletions
4948
- for (const [filePath, fileEntry] of this.files) {
4949
- // File deleted
4950
- if (!fs.existsSync(filePath)) {
4951
- return true;
4952
- }
4953
-
4954
- // File modified - check size first, then mtime, then hash
4955
- try {
4956
- const stat = fs.statSync(filePath);
4957
-
4958
- // If size changed, file changed
4959
- if (fileEntry.size !== undefined && stat.size !== fileEntry.size) {
4960
- return true;
4961
- }
4962
-
4963
- // If mtime matches, file hasn't changed
4964
- if (fileEntry.mtime && stat.mtimeMs === fileEntry.mtime) {
4965
- continue;
4966
- }
4967
-
4968
- // mtime changed or not stored - verify with hash
4969
- const content = fs.readFileSync(filePath, 'utf-8');
4970
- const hash = crypto.createHash('md5').update(content).digest('hex');
4971
- if (hash !== fileEntry.hash) {
4972
- return true;
4973
- }
4974
- } catch (e) {
4975
- return true;
4976
- }
4977
- }
4978
-
4979
- return false;
4980
- }
3121
+ /** Check if cache is stale (any files changed or new files added) */
3122
+ isCacheStale() { return indexCache.isCacheStale(this); }
4981
3123
 
4982
3124
  /**
4983
3125
  * Find the best usage example of a function.
@@ -5040,74 +3182,8 @@ class ProjectIndex {
5040
3182
  } finally { this._endOp(); }
5041
3183
  }
5042
3184
 
5043
- /**
5044
- * Analyze a call site using AST for example scoring.
5045
- * @private
5046
- */
5047
- _analyzeCallSiteAST(filePath, lineNum, funcName) {
5048
- const result = {
5049
- isAwait: false, isDestructured: false, isTypedAssignment: false,
5050
- isInReturn: false, isInCatch: false, isInConditional: false,
5051
- hasComment: false, isStandalone: false
5052
- };
5053
-
5054
- try {
5055
- const language = detectLanguage(filePath);
5056
- if (!language) return result;
5057
-
5058
- const parser = getParser(language);
5059
- const content = this._readFile(filePath);
5060
- const tree = safeParse(parser, content);
5061
- if (!tree) return result;
5062
-
5063
- const row = lineNum - 1;
5064
- const node = tree.rootNode.descendantForPosition({ row, column: 0 });
5065
- if (!node) return result;
5066
-
5067
- let current = node;
5068
- let foundCall = false;
5069
-
5070
- while (current) {
5071
- const type = current.type;
5072
-
5073
- if (!foundCall && (type === 'call_expression' || type === 'call')) {
5074
- const calleeNode = current.childForFieldName('function') || current.namedChild(0);
5075
- if (calleeNode && calleeNode.text === funcName) {
5076
- foundCall = true;
5077
- }
5078
- }
5079
-
5080
- if (foundCall) {
5081
- if (type === 'await_expression') result.isAwait = true;
5082
- if (type === 'variable_declarator' || type === 'assignment_expression') {
5083
- const parent = current.parent;
5084
- if (parent && (parent.type === 'lexical_declaration' || parent.type === 'variable_declaration')) {
5085
- result.isTypedAssignment = true;
5086
- }
5087
- }
5088
- if (type === 'array_pattern' || type === 'object_pattern') result.isDestructured = true;
5089
- if (type === 'return_statement') result.isInReturn = true;
5090
- if (type === 'catch_clause' || type === 'except_clause') result.isInCatch = true;
5091
- if (type === 'if_statement' || type === 'conditional_expression' || type === 'ternary_expression') result.isInConditional = true;
5092
- if (type === 'expression_statement') result.isStandalone = true;
5093
- }
5094
-
5095
- current = current.parent;
5096
- }
5097
-
5098
- const contentLines = content.split('\n');
5099
- if (lineNum > 1) {
5100
- const prevLine = contentLines[lineNum - 2].trim();
5101
- if (prevLine.startsWith('//') || prevLine.startsWith('#') || prevLine.endsWith('*/')) {
5102
- result.hasComment = true;
5103
- }
5104
- }
5105
- } catch (e) {
5106
- // Return default result on error
5107
- }
5108
-
5109
- return result;
5110
- }
3185
+ /** Analyze a call site using AST for example scoring */
3186
+ _analyzeCallSiteAST(filePath, lineNum, funcName) { return verifyModule.analyzeCallSiteAST(this, filePath, lineNum, funcName); }
5111
3187
 
5112
3188
  /**
5113
3189
  * Diff-based impact analysis: find which functions changed and who calls them
@@ -5128,7 +3204,7 @@ class ProjectIndex {
5128
3204
  // Verify git repo
5129
3205
  let gitRoot;
5130
3206
  try {
5131
- gitRoot = execSync('git rev-parse --show-toplevel', { cwd: this.root, encoding: 'utf-8' }).trim();
3207
+ gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: this.root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
5132
3208
  } catch (e) {
5133
3209
  throw new Error('Not a git repository. diff-impact requires git.');
5134
3210
  }
@@ -5167,7 +3243,24 @@ class ProjectIndex {
5167
3243
  };
5168
3244
  }
5169
3245
 
5170
- const changes = parseDiff(diffText, this.root);
3246
+ // Diff paths are git-root-relative. Resolve to this.root for file lookup.
3247
+ // Normalize both through realpath to handle macOS /var → /private/var symlinks.
3248
+ let realGitRoot, realProjectRoot;
3249
+ try { realGitRoot = fs.realpathSync(gitRoot); } catch (_) { realGitRoot = gitRoot; }
3250
+ try { realProjectRoot = fs.realpathSync(this.root); } catch (_) { realProjectRoot = this.root; }
3251
+ const projectPrefix = realGitRoot === realProjectRoot
3252
+ ? ''
3253
+ : path.relative(realGitRoot, realProjectRoot);
3254
+
3255
+ const rawChanges = parseDiff(diffText, gitRoot);
3256
+ // Filter to files under this.root and remap paths.
3257
+ // Preserve gitRelativePath (repo-relative) for git show commands.
3258
+ const changes = [];
3259
+ for (const c of rawChanges) {
3260
+ if (projectPrefix && !c.relativePath.startsWith(projectPrefix + '/')) continue;
3261
+ const localRel = projectPrefix ? c.relativePath.slice(projectPrefix.length + 1) : c.relativePath;
3262
+ changes.push({ ...c, gitRelativePath: c.relativePath, filePath: path.join(this.root, localRel), relativePath: localRel });
3263
+ }
5171
3264
 
5172
3265
  const functions = [];
5173
3266
  const moduleLevelChanges = [];
@@ -5188,8 +3281,8 @@ class ProjectIndex {
5188
3281
  const ref = staged ? 'HEAD' : base;
5189
3282
  try {
5190
3283
  const oldContent = execFileSync(
5191
- 'git', ['show', `${ref}:${change.relativePath}`],
5192
- { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
3284
+ 'git', ['show', `${ref}:${change.gitRelativePath}`],
3285
+ { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
5193
3286
  );
5194
3287
  const oldParsed = parse(oldContent, lang);
5195
3288
  for (const oldFn of extractCallableSymbols(oldParsed)) {
@@ -5292,8 +3385,8 @@ class ProjectIndex {
5292
3385
  const ref = staged ? 'HEAD' : base;
5293
3386
  try {
5294
3387
  const oldContent = execFileSync(
5295
- 'git', ['show', `${ref}:${change.relativePath}`],
5296
- { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
3388
+ 'git', ['show', `${ref}:${change.gitRelativePath}`],
3389
+ { cwd: this.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
5297
3390
  );
5298
3391
  const fileLang = detectLanguage(change.filePath);
5299
3392
  if (fileLang) {