ucn 3.8.13 → 3.8.15

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.
Files changed (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +3 -1
  2. package/.github/workflows/ci.yml +13 -1
  3. package/README.md +1 -0
  4. package/cli/index.js +165 -246
  5. package/core/analysis.js +1400 -0
  6. package/core/build-worker.js +194 -0
  7. package/core/cache.js +105 -7
  8. package/core/callers.js +194 -64
  9. package/core/deadcode.js +22 -66
  10. package/core/discovery.js +9 -54
  11. package/core/execute.js +139 -54
  12. package/core/graph.js +615 -0
  13. package/core/imports.js +50 -16
  14. package/core/output/analysis-ext.js +271 -0
  15. package/core/output/analysis.js +491 -0
  16. package/core/output/extraction.js +188 -0
  17. package/core/output/find.js +355 -0
  18. package/core/output/graph.js +399 -0
  19. package/core/output/refactoring.js +293 -0
  20. package/core/output/reporting.js +331 -0
  21. package/core/output/search.js +307 -0
  22. package/core/output/shared.js +271 -0
  23. package/core/output/tracing.js +416 -0
  24. package/core/output.js +15 -3293
  25. package/core/parallel-build.js +165 -0
  26. package/core/project.js +299 -3633
  27. package/core/registry.js +59 -0
  28. package/core/reporting.js +258 -0
  29. package/core/search.js +890 -0
  30. package/core/stacktrace.js +1 -1
  31. package/core/tracing.js +631 -0
  32. package/core/verify.js +10 -13
  33. package/eslint.config.js +43 -0
  34. package/jsconfig.json +10 -0
  35. package/languages/go.js +21 -2
  36. package/languages/html.js +8 -0
  37. package/languages/index.js +102 -40
  38. package/languages/java.js +13 -0
  39. package/languages/javascript.js +17 -1
  40. package/languages/python.js +14 -0
  41. package/languages/rust.js +13 -0
  42. package/languages/utils.js +1 -1
  43. package/mcp/server.js +45 -28
  44. package/package.json +8 -3
@@ -0,0 +1,1400 @@
1
+ /**
2
+ * core/analysis.js — Analysis commands (context, smart, related, impact, about, diffImpact, detectCompleteness)
3
+ *
4
+ * Extracted from project.js. All functions take an `index` (ProjectIndex)
5
+ * as the first argument instead of using `this`.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { execFileSync } = require('child_process');
13
+ const { parse } = require('./parser');
14
+ const { detectLanguage, langTraits } = require('../languages');
15
+ const { NON_CALLABLE_TYPES, addTestExclusions } = require('./shared');
16
+
17
+ /**
18
+ * Context: quick caller/callee view for a symbol.
19
+ *
20
+ * @param {object} index - ProjectIndex instance
21
+ * @param {string} name - Symbol name
22
+ * @param {object} options - { file, className, includeMethods, includeUncertain, exclude, minConfidence }
23
+ * @returns {object|null}
24
+ */
25
+ function context(index, name, options = {}) {
26
+ index._beginOp();
27
+ try {
28
+ const resolved = index.resolveSymbol(name, { file: options.file, className: options.className });
29
+ let { def, warnings } = resolved;
30
+ if (!def) {
31
+ return null;
32
+ }
33
+
34
+ // Special handling for class/struct/interface types
35
+ if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
36
+ const methods = index.findMethodsForType(name);
37
+
38
+ let typeCallers = index.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain });
39
+ // Apply exclude filter
40
+ if (options.exclude && options.exclude.length > 0) {
41
+ typeCallers = typeCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
42
+ }
43
+
44
+ const result = {
45
+ type: def.type,
46
+ name: name,
47
+ file: def.relativePath,
48
+ startLine: def.startLine,
49
+ endLine: def.endLine,
50
+ methods: methods.map(m => ({
51
+ name: m.name,
52
+ file: m.relativePath,
53
+ line: m.startLine,
54
+ params: m.params,
55
+ returnType: m.returnType,
56
+ receiver: m.receiver
57
+ })),
58
+ // Also include places where the type is used in function parameters/returns
59
+ callers: typeCallers
60
+ };
61
+
62
+ if (warnings.length > 0) {
63
+ result.warnings = warnings;
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ const stats = { uncertain: 0 };
70
+ let callers = index.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats, targetDefinitions: [def] });
71
+ let callees = index.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
72
+
73
+ // Apply exclude filter
74
+ if (options.exclude && options.exclude.length > 0) {
75
+ callers = callers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
76
+ callees = callees.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
77
+ }
78
+
79
+ // Apply confidence filtering
80
+ let confidenceFiltered = 0;
81
+ if (options.minConfidence > 0) {
82
+ const { filterByConfidence } = require('./confidence');
83
+ const callerResult = filterByConfidence(callers, options.minConfidence);
84
+ const calleeResult = filterByConfidence(callees, options.minConfidence);
85
+ callers = callerResult.kept;
86
+ callees = calleeResult.kept;
87
+ confidenceFiltered = callerResult.filtered + calleeResult.filtered;
88
+ }
89
+
90
+ const filesInScope = new Set([def.file]);
91
+ callers.forEach(c => filesInScope.add(c.file));
92
+ callees.forEach(c => filesInScope.add(c.file));
93
+ let dynamicImports = 0;
94
+ for (const f of filesInScope) {
95
+ const fe = index.files.get(f);
96
+ if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
97
+ }
98
+
99
+ const result = {
100
+ function: name,
101
+ file: def.relativePath,
102
+ startLine: def.startLine,
103
+ endLine: def.endLine,
104
+ params: def.params,
105
+ returnType: def.returnType,
106
+ callers,
107
+ callees,
108
+ meta: {
109
+ complete: stats.uncertain === 0 && dynamicImports === 0 && confidenceFiltered === 0,
110
+ skipped: 0,
111
+ dynamicImports,
112
+ uncertain: stats.uncertain,
113
+ confidenceFiltered,
114
+ includeMethods: !!options.includeMethods,
115
+ projectLanguage: index._getPredominantLanguage(),
116
+ // Structural facts for reliability hints
117
+ ...(def.isMethod && { isMethod: true }),
118
+ ...(def.className && { className: def.className }),
119
+ ...(def.receiver && { receiver: def.receiver })
120
+ }
121
+ };
122
+
123
+ if (warnings.length > 0) {
124
+ result.warnings = warnings;
125
+ }
126
+
127
+ return result;
128
+ } finally { index._endOp(); }
129
+ }
130
+
131
+ /**
132
+ * Smart extraction: function + dependencies inline.
133
+ *
134
+ * @param {object} index - ProjectIndex instance
135
+ * @param {string} name - Symbol name
136
+ * @param {object} options - { file, className, includeMethods, includeUncertain, withTypes }
137
+ * @returns {object|null}
138
+ */
139
+ function smart(index, name, options = {}) {
140
+ index._beginOp();
141
+ try {
142
+ const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
143
+ if (!def) {
144
+ return null;
145
+ }
146
+ const code = index.extractCode(def);
147
+ const stats = { uncertain: 0 };
148
+ const callees = index.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
149
+
150
+ const filesInScope = new Set([def.file]);
151
+ callees.forEach(c => filesInScope.add(c.file));
152
+ let dynamicImports = 0;
153
+ for (const f of filesInScope) {
154
+ const fe = index.files.get(f);
155
+ if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
156
+ }
157
+
158
+ // Extract code for each dependency, excluding the exact same function
159
+ // (but keeping same-name overloads, e.g. Java toJson(Object) vs toJson(Object, Class))
160
+ const defBindingId = def.bindingId;
161
+ const dependencies = callees
162
+ .filter(callee => callee.bindingId !== defBindingId)
163
+ .map(callee => ({
164
+ ...callee,
165
+ code: index.extractCode(callee)
166
+ }));
167
+
168
+ // Find type definitions if requested
169
+ const types = [];
170
+ if (options.withTypes) {
171
+ // Look for type annotations in params/return type
172
+ const typeNames = index.extractTypeNames(def);
173
+ for (const typeName of typeNames) {
174
+ const typeSymbols = index.symbols.get(typeName);
175
+ if (typeSymbols) {
176
+ for (const sym of typeSymbols) {
177
+ if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
178
+ types.push({
179
+ ...sym,
180
+ code: index.extractCode(sym)
181
+ });
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ return {
189
+ target: {
190
+ ...def,
191
+ code
192
+ },
193
+ dependencies,
194
+ types,
195
+ meta: {
196
+ complete: stats.uncertain === 0 && dynamicImports === 0,
197
+ skipped: 0,
198
+ dynamicImports,
199
+ uncertain: stats.uncertain,
200
+ projectLanguage: index._getPredominantLanguage()
201
+ }
202
+ };
203
+ } finally { index._endOp(); }
204
+ }
205
+
206
+ /**
207
+ * Detect completeness signal metadata for the project.
208
+ *
209
+ * @param {object} index - ProjectIndex instance
210
+ * @returns {object} { complete, warnings, projectLanguage }
211
+ */
212
+ function detectCompleteness(index) {
213
+ // Return cached result if available
214
+ if (index._completenessCache) {
215
+ return index._completenessCache;
216
+ }
217
+
218
+ const warnings = [];
219
+ let dynamicImports = 0;
220
+ let evalUsage = 0;
221
+ let reflectionUsage = 0;
222
+
223
+ for (const [filePath, fileEntry] of index.files) {
224
+ // Skip node_modules - we don't care about their patterns
225
+ if (filePath.includes('node_modules')) continue;
226
+
227
+ try {
228
+ const content = index._readFile(filePath);
229
+
230
+ if (langTraits(fileEntry.language)?.hasDynamicImports) {
231
+ // Dynamic imports: import(), require(variable), __import__
232
+ dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
233
+ dynamicImports += (content.match(/require\s*\([^'"]/g) || []).length;
234
+ dynamicImports += (content.match(/__import__\s*\(/g) || []).length;
235
+
236
+ // eval, Function constructor
237
+ evalUsage += (content.match(/(^|[^a-zA-Z_])eval\s*\(/gm) || []).length;
238
+ evalUsage += (content.match(/new\s+Function\s*\(/g) || []).length;
239
+ }
240
+
241
+ // Reflection: getattr, hasattr, Reflect
242
+ reflectionUsage += (content.match(/\bgetattr\s*\(/g) || []).length;
243
+ reflectionUsage += (content.match(/\bhasattr\s*\(/g) || []).length;
244
+ reflectionUsage += (content.match(/\bReflect\./g) || []).length;
245
+ } catch (e) {
246
+ // Skip unreadable files
247
+ }
248
+ }
249
+
250
+ if (dynamicImports > 0) {
251
+ warnings.push({
252
+ type: 'dynamic_imports',
253
+ count: dynamicImports,
254
+ message: `${dynamicImports} dynamic import(s) detected - some dependencies may be missed`
255
+ });
256
+ }
257
+
258
+ if (evalUsage > 0) {
259
+ warnings.push({
260
+ type: 'eval',
261
+ count: evalUsage,
262
+ message: `${evalUsage} eval/exec usage(s) detected - dynamically generated code not analyzed`
263
+ });
264
+ }
265
+
266
+ if (reflectionUsage > 0) {
267
+ warnings.push({
268
+ type: 'reflection',
269
+ count: reflectionUsage,
270
+ message: `${reflectionUsage} reflection usage(s) detected - dynamic attribute access not tracked`
271
+ });
272
+ }
273
+
274
+ index._completenessCache = {
275
+ complete: warnings.length === 0,
276
+ warnings,
277
+ projectLanguage: index._getPredominantLanguage()
278
+ };
279
+
280
+ return index._completenessCache;
281
+ }
282
+
283
+ /**
284
+ * Find related functions — same file, similar names, shared dependencies.
285
+ *
286
+ * @param {object} index - ProjectIndex instance
287
+ * @param {string} name - Function name
288
+ * @param {object} options - { file, className, top, all }
289
+ * @returns {object|null}
290
+ */
291
+ function related(index, name, options = {}) {
292
+ index._beginOp();
293
+ try {
294
+ const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
295
+ if (!def) {
296
+ return null;
297
+ }
298
+ const related = {
299
+ target: {
300
+ name: def.name,
301
+ file: def.relativePath,
302
+ line: def.startLine,
303
+ type: def.type
304
+ },
305
+ sameFile: [],
306
+ similarNames: [],
307
+ sharedCallers: [],
308
+ sharedCallees: []
309
+ };
310
+
311
+ // 1. Same file functions (sorted by proximity to target)
312
+ const fileEntry = index.files.get(def.file);
313
+ if (fileEntry) {
314
+ for (const sym of fileEntry.symbols) {
315
+ if (sym.name !== name && !NON_CALLABLE_TYPES.has(sym.type)) {
316
+ related.sameFile.push({
317
+ name: sym.name,
318
+ line: sym.startLine,
319
+ params: sym.params
320
+ });
321
+ }
322
+ }
323
+ // Sort by distance from target function (nearest first)
324
+ related.sameFile.sort((a, b) =>
325
+ Math.abs(a.line - def.startLine) - Math.abs(b.line - def.startLine)
326
+ );
327
+ }
328
+
329
+ // 2. Similar names (shared prefix/suffix, camelCase similarity)
330
+ const nameParts = name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
331
+ for (const [symName, symbols] of index.symbols) {
332
+ if (symName === name) continue;
333
+ const symParts = symName.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
334
+
335
+ // Check for shared parts (require >=50% of the longer name to match)
336
+ const sharedParts = nameParts.filter(p => symParts.includes(p) && p.length > 3);
337
+ const maxParts = Math.max(nameParts.length, symParts.length);
338
+ if (sharedParts.length > 0 && sharedParts.length / maxParts >= 0.5) {
339
+ const sym = symbols[0];
340
+ related.similarNames.push({
341
+ name: symName,
342
+ file: sym.relativePath,
343
+ line: sym.startLine,
344
+ sharedParts,
345
+ type: sym.type
346
+ });
347
+ }
348
+ }
349
+ // Sort by number of shared parts
350
+ related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
351
+ const similarLimit = options.top || (options.all ? Infinity : 10);
352
+ related.similarNamesTotal = related.similarNames.length;
353
+ if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
354
+
355
+ // 3. Shared callers - functions called by the same callers
356
+ const myCallers = new Set(index.findCallers(name).map(c => c.callerName).filter(Boolean));
357
+ if (myCallers.size > 0) {
358
+ const callerCounts = new Map();
359
+ for (const callerName of myCallers) {
360
+ const callerDef = index.symbols.get(callerName)?.[0];
361
+ if (callerDef) {
362
+ const callees = index.findCallees(callerDef);
363
+ for (const callee of callees) {
364
+ if (callee.name !== name) {
365
+ callerCounts.set(callee.name, (callerCounts.get(callee.name) || 0) + 1);
366
+ }
367
+ }
368
+ }
369
+ }
370
+ // Sort by shared caller count
371
+ const maxShared = options.top || (options.all ? Infinity : 5);
372
+ const allSorted = Array.from(callerCounts.entries())
373
+ .sort((a, b) => b[1] - a[1]);
374
+ related.sharedCallersTotal = allSorted.length;
375
+ const sorted = allSorted.slice(0, maxShared);
376
+ for (const [symName, count] of sorted) {
377
+ const sym = index.symbols.get(symName)?.[0];
378
+ if (sym) {
379
+ related.sharedCallers.push({
380
+ name: symName,
381
+ file: sym.relativePath,
382
+ line: sym.startLine,
383
+ sharedCallerCount: count
384
+ });
385
+ }
386
+ }
387
+ }
388
+
389
+ // 4. Shared callees - functions that call the same things
390
+ // Optimized: instead of computing callees for every symbol (O(N*M)),
391
+ // find who else calls each of our callees (O(K) where K = our callee count)
392
+ if (def.type === 'function' || def.params !== undefined) {
393
+ const myCallees = index.findCallees(def);
394
+ const myCalleeNames = new Set(myCallees.map(c => c.name));
395
+ if (myCalleeNames.size > 0) {
396
+ const calleeCounts = new Map();
397
+ for (const calleeName of myCalleeNames) {
398
+ // Find other functions that also call this callee
399
+ const callers = index.findCallers(calleeName);
400
+ for (const caller of callers) {
401
+ if (caller.callerName && caller.callerName !== name) {
402
+ calleeCounts.set(caller.callerName, (calleeCounts.get(caller.callerName) || 0) + 1);
403
+ }
404
+ }
405
+ }
406
+ // Sort by shared callee count
407
+ const allSorted = Array.from(calleeCounts.entries())
408
+ .sort((a, b) => b[1] - a[1]);
409
+ related.sharedCalleesTotal = allSorted.length;
410
+ const sorted = allSorted.slice(0, options.top || (options.all ? Infinity : 5));
411
+ for (const [symName, count] of sorted) {
412
+ const sym = index.symbols.get(symName)?.[0];
413
+ if (sym) {
414
+ related.sharedCallees.push({
415
+ name: symName,
416
+ file: sym.relativePath,
417
+ line: sym.startLine,
418
+ sharedCalleeCount: count
419
+ });
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ return related;
426
+ } finally { index._endOp(); }
427
+ }
428
+
429
+ /**
430
+ * Impact analysis — what call sites need updating if a function changes.
431
+ *
432
+ * @param {object} index - ProjectIndex instance
433
+ * @param {string} name - Function name
434
+ * @param {object} options - { file, className, exclude, top }
435
+ * @returns {object|null}
436
+ */
437
+ function impact(index, name, options = {}) {
438
+ index._beginOp();
439
+ try {
440
+ const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
441
+ if (!def) {
442
+ return null;
443
+ }
444
+ const defIsMethod = def.isMethod || def.type === 'method' || def.className || def.receiver;
445
+
446
+ // Use findCallers for className-scoped or method queries (sophisticated binding resolution)
447
+ // Fall back to usages-based approach for simple function queries (backward compatible)
448
+ let callSites;
449
+ if (options.className || defIsMethod) {
450
+ // findCallers has proper method call resolution (self/this, binding IDs, receiver checks)
451
+ let callerResults = index.findCallers(name, {
452
+ includeMethods: true,
453
+ includeUncertain: false,
454
+ targetDefinitions: [def],
455
+ });
456
+
457
+ // When the target definition has a className (including Go/Rust methods which
458
+ // now get className from receiver), filter out method calls whose receiver
459
+ // clearly belongs to a different type. This helps with common method names
460
+ // like .close(), .get() etc. where many types have the same method.
461
+ if (def.className) {
462
+ const targetClassName = def.className;
463
+ // Pre-compute how many types share this method name
464
+ const _impMethodDefs = index.symbols.get(name);
465
+ const _impClassNames = new Set();
466
+ if (_impMethodDefs) {
467
+ for (const d of _impMethodDefs) {
468
+ if (d.className) _impClassNames.add(d.className);
469
+ else if (d.receiver) _impClassNames.add(d.receiver.replace(/^\*/, ''));
470
+ }
471
+ }
472
+ callerResults = callerResults.filter(c => {
473
+ // Keep non-method calls and self/this/cls calls (already resolved by findCallers)
474
+ if (!c.isMethod) return true;
475
+ const r = c.receiver;
476
+ if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
477
+ // Use receiverType from findCallers when available (Go/Java/Rust type inference)
478
+ if (c.receiverType) {
479
+ return c.receiverType === targetClassName;
480
+ }
481
+ // No receiver (chained/complex expression): only include if method is
482
+ // unique or rare across types — otherwise too many false positives
483
+ if (!r) {
484
+ return _impClassNames.size <= 1;
485
+ }
486
+ // Check if receiver matches the target class name (case-insensitive camelCase convention)
487
+ if (r.toLowerCase().includes(targetClassName.toLowerCase())) return true;
488
+ // Check if receiver is an instance of the target class using local variable type inference
489
+ if (c.callerFile) {
490
+ const callerDef = c.callerStartLine ? { file: c.callerFile, startLine: c.callerStartLine, endLine: c.callerEndLine } : null;
491
+ if (callerDef) {
492
+ const callerCalls = index.getCachedCalls(c.callerFile);
493
+ if (callerCalls && Array.isArray(callerCalls)) {
494
+ const localTypes = new Map();
495
+ for (const call of callerCalls) {
496
+ if (call.line >= callerDef.startLine && call.line <= callerDef.endLine) {
497
+ if (!call.isMethod && !call.receiver) {
498
+ const syms = index.symbols.get(call.name);
499
+ if (syms && syms.some(s => s.type === 'class')) {
500
+ // Found a constructor call — check for assignment pattern
501
+ const fileEntry = index.files.get(c.callerFile);
502
+ if (fileEntry) {
503
+ const content = index._readFile(c.callerFile);
504
+ const lines = content.split('\n');
505
+ const line = lines[call.line - 1] || '';
506
+ // Match "var = ClassName(...)" or "var = new ClassName(...)" or "Type var = new ClassName<>(...)"
507
+ const m = line.match(/(\w+)\s*=\s*(?:await\s+)?(?:new\s+)?(\w+)\s*(?:<[^>]*>)?\s*\(/);
508
+ if (m && m[2] === call.name) {
509
+ localTypes.set(m[1], call.name);
510
+ }
511
+ }
512
+ }
513
+ }
514
+ }
515
+ }
516
+ const receiverType = localTypes.get(r);
517
+ if (receiverType) {
518
+ return receiverType === targetClassName;
519
+ }
520
+ }
521
+ }
522
+ }
523
+ // Check class field declarations for receiver type: private DataService service
524
+ if (c.callerFile) {
525
+ const callerEnclosing = index.findEnclosingFunction(c.callerFile, c.line, true);
526
+ if (callerEnclosing?.className) {
527
+ const classSyms = index.symbols.get(callerEnclosing.className);
528
+ if (classSyms) {
529
+ const classDef = classSyms.find(s => s.type === 'class' || s.type === 'struct' || s.type === 'interface');
530
+ if (classDef) {
531
+ const content = index._readFile(c.callerFile);
532
+ const lines = content.split('\n');
533
+ // Scan class body for field declarations matching the receiver
534
+ for (let li = classDef.startLine - 1; li < (classDef.endLine || classDef.startLine + 50) && li < lines.length; li++) {
535
+ const line = lines[li];
536
+ // Match Java/TS field: [modifiers] TypeName<...> receiverName [= ...]
537
+ const fieldMatch = line.match(new RegExp(`\\b(\\w+)(?:<[^>]*>)?\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}\\s*[;=]`));
538
+ if (fieldMatch) {
539
+ const fieldType = fieldMatch[1];
540
+ if (fieldType === targetClassName) return true;
541
+ break;
542
+ }
543
+ }
544
+ }
545
+ }
546
+ }
547
+ }
548
+ // Check parameter type annotations: def foo(tracker: SourceTracker) → tracker.record()
549
+ if (c.callerFile && c.callerStartLine) {
550
+ const callerSymbol = index.findEnclosingFunction(c.callerFile, c.line, true);
551
+ if (callerSymbol && callerSymbol.paramsStructured) {
552
+ for (const param of callerSymbol.paramsStructured) {
553
+ if (param.name === r && param.type) {
554
+ // Check if the type annotation contains the target class name
555
+ const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
556
+ if (typeMatches && typeMatches.some(t => t === targetClassName)) {
557
+ return true;
558
+ }
559
+ // Type annotation exists but doesn't match target class — filter out
560
+ return false;
561
+ }
562
+ }
563
+ }
564
+ }
565
+ // Unique method heuristic: if the called method exists on exactly one class/type
566
+ // and it matches the target, include the call (no other class could match)
567
+ if (_impClassNames.size === 1 && _impClassNames.has(targetClassName)) {
568
+ return true;
569
+ }
570
+ // Type-scoped query but receiver type unknown — filter it out.
571
+ // Unknown receivers are likely unrelated.
572
+ return false;
573
+ });
574
+ }
575
+
576
+ callSites = [];
577
+ for (const c of callerResults) {
578
+ const analysis = index.analyzeCallSite(
579
+ { file: c.file, relativePath: c.relativePath, line: c.line, content: c.content },
580
+ name
581
+ );
582
+ callSites.push({
583
+ file: c.relativePath,
584
+ line: c.line,
585
+ expression: c.content.trim(),
586
+ callerName: c.callerName,
587
+ ...analysis
588
+ });
589
+ }
590
+ index._clearTreeCache();
591
+ } else {
592
+ // Use findCallers (benefits from callee index) instead of usages() for speed
593
+ const callerResults = index.findCallers(name, {
594
+ includeMethods: false,
595
+ includeUncertain: false,
596
+ targetDefinitions: [def],
597
+ });
598
+ const targetBindingId = def.bindingId;
599
+ // Convert findCallers results to the format expected by analyzeCallSite
600
+ const calls = callerResults.map(c => ({
601
+ file: c.file,
602
+ relativePath: c.relativePath,
603
+ line: c.line,
604
+ content: c.content,
605
+ usageType: 'call',
606
+ callerName: c.callerName,
607
+ }));
608
+ // Keep the same binding filter for backward compat (findCallers already handles this,
609
+ // but cross-check with usages-based binding filter for safety)
610
+ const filteredCalls = calls.filter(u => {
611
+ const fileEntry = index.files.get(u.file);
612
+ if (fileEntry && targetBindingId) {
613
+ let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
614
+ if (localBindings.length === 0 && langTraits(fileEntry.language)?.packageScope === 'directory') {
615
+ const dir = path.dirname(u.file);
616
+ for (const [fp, fe] of index.files) {
617
+ if (fp !== u.file && path.dirname(fp) === dir) {
618
+ const sibling = (fe.bindings || []).filter(b => b.name === name);
619
+ localBindings = localBindings.concat(sibling);
620
+ }
621
+ }
622
+ }
623
+ if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
624
+ return false;
625
+ }
626
+ }
627
+ return true;
628
+ });
629
+ // (findCallers already handles binding resolution and scope-aware filtering)
630
+
631
+ // Analyze each call site, filtering out method calls for non-method definitions
632
+ callSites = [];
633
+ const defFileEntry = index.files.get(def.file);
634
+ const defLang = defFileEntry?.language;
635
+ const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
636
+ for (const call of filteredCalls) {
637
+ const analysis = index.analyzeCallSite(call, name);
638
+ // Skip method calls (obj.parse()) when target is a standalone function (parse())
639
+ // For Go, allow calls where receiver matches the package directory name
640
+ // (e.g., controller.FilterActive() where file is in pkg/controller/)
641
+ if (analysis.isMethodCall && !defIsMethod) {
642
+ if (targetDir) {
643
+ // Get receiver from parsed calls cache
644
+ const parsedCalls = index.getCachedCalls(call.file);
645
+ const matchedCall = parsedCalls?.find(c => c.name === name && c.line === call.line);
646
+ if (matchedCall?.receiver === targetDir) {
647
+ // Receiver matches package directory — keep it
648
+ } else {
649
+ continue;
650
+ }
651
+ } else {
652
+ continue;
653
+ }
654
+ }
655
+ callSites.push({
656
+ file: call.relativePath,
657
+ line: call.line,
658
+ expression: call.content.trim(),
659
+ callerName: call.callerName || index.findEnclosingFunction(call.file, call.line),
660
+ ...analysis
661
+ });
662
+ }
663
+ index._clearTreeCache();
664
+ }
665
+
666
+ // Apply exclude filter
667
+ let filteredSites = callSites;
668
+ if (options.exclude && options.exclude.length > 0) {
669
+ filteredSites = callSites.filter(s => index.matchesFilters(s.file, { exclude: options.exclude }));
670
+ }
671
+
672
+ // Apply top limit if specified (limits total call sites shown)
673
+ const totalBeforeLimit = filteredSites.length;
674
+ if (options.top && options.top > 0 && filteredSites.length > options.top) {
675
+ filteredSites = filteredSites.slice(0, options.top);
676
+ }
677
+
678
+ // Group by file
679
+ const byFile = new Map();
680
+ for (const site of filteredSites) {
681
+ if (!byFile.has(site.file)) {
682
+ byFile.set(site.file, []);
683
+ }
684
+ byFile.get(site.file).push(site);
685
+ }
686
+
687
+ // Identify patterns
688
+ const patterns = index.identifyCallPatterns(filteredSites, name);
689
+
690
+ // Detect scope pollution: multiple class definitions for the same method name
691
+ let scopeWarning = null;
692
+ if (defIsMethod) {
693
+ const allDefs = index.symbols.get(name);
694
+ if (allDefs && allDefs.length > 1) {
695
+ const classNames = [...new Set(allDefs
696
+ .filter(d => d.className && d.className !== def.className)
697
+ .map(d => d.className))];
698
+ if (classNames.length > 0 && !options.className && !options.file) {
699
+ scopeWarning = {
700
+ targetClass: def.className || '(unknown)',
701
+ otherClasses: classNames,
702
+ hint: `Results may include calls to ${classNames.join(', ')}.${name}(). Use file= or className= to narrow scope.`
703
+ };
704
+ }
705
+ }
706
+ }
707
+
708
+ return {
709
+ function: name,
710
+ file: def.relativePath,
711
+ startLine: def.startLine,
712
+ signature: index.formatSignature(def),
713
+ params: def.params,
714
+ paramsStructured: def.paramsStructured,
715
+ totalCallSites: totalBeforeLimit,
716
+ shownCallSites: filteredSites.length,
717
+ byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
718
+ file,
719
+ count: sites.length,
720
+ sites
721
+ })),
722
+ patterns,
723
+ scopeWarning
724
+ };
725
+ } finally { index._endOp(); }
726
+ }
727
+
728
+ /**
729
+ * About: comprehensive symbol metadata — definition, usages, callers, callees, tests, code.
730
+ *
731
+ * @param {object} index - ProjectIndex instance
732
+ * @param {string} name - Symbol name
733
+ * @param {object} options - { file, className, all, maxCallers, maxCallees, withCode, withTypes,
734
+ * includeMethods, includeUncertain, includeTests, exclude, minConfidence }
735
+ * @returns {object|null}
736
+ */
737
+ function about(index, name, options = {}) {
738
+ index._beginOp();
739
+ try {
740
+ const maxCallers = options.all ? Infinity : (options.maxCallers || 10);
741
+ const maxCallees = options.all ? Infinity : (options.maxCallees || 10);
742
+
743
+ // Find symbol definition(s) — skip counts since about() computes its own via usages()
744
+ const definitions = index.find(name, { exact: true, file: options.file, className: options.className, skipCounts: true });
745
+ if (definitions.length === 0) {
746
+ // Try fuzzy match (needs counts for suggestion ranking)
747
+ const fuzzy = index.find(name, { file: options.file, className: options.className });
748
+ if (fuzzy.length === 0) {
749
+ return null;
750
+ }
751
+ // Return suggestion
752
+ return {
753
+ found: false,
754
+ suggestions: (options.all ? fuzzy : fuzzy.slice(0, 5)).map(s => ({
755
+ name: s.name,
756
+ file: s.relativePath,
757
+ line: s.startLine,
758
+ type: s.type,
759
+ usageCount: s.usageCount
760
+ }))
761
+ };
762
+ }
763
+
764
+ // Use resolveSymbol for consistent primary selection (prefers non-test files)
765
+ const { def: resolved } = index.resolveSymbol(name, { file: options.file, className: options.className });
766
+ const primary = resolved || definitions[0];
767
+ const others = definitions.filter(d =>
768
+ d.relativePath !== primary.relativePath || d.startLine !== primary.startLine
769
+ );
770
+
771
+ // Use the actual symbol name (may differ from query if fuzzy matched)
772
+ const symbolName = primary.name;
773
+
774
+ // Default includeMethods: true when target is a class method (method calls are the primary way
775
+ // class methods are invoked), false for standalone functions (reduces noise from unrelated obj.fn() calls)
776
+ const isMethod = !!(primary.isMethod || primary.type === 'method' || primary.className);
777
+ const includeMethods = options.includeMethods ?? isMethod;
778
+
779
+ // Get usage counts by type (fast path uses callee index, no file reads)
780
+ // Exclude test files by default (matching usages command behavior)
781
+ const countExclude = !options.includeTests ? addTestExclusions(options.exclude) : options.exclude;
782
+ const usagesByType = index.countSymbolUsages(primary, { exclude: countExclude });
783
+
784
+ // Get callers and callees (only for functions)
785
+ let callers = [];
786
+ let callees = [];
787
+ let allCallers = null;
788
+ let allCallees = null;
789
+ let aboutConfFiltered = 0;
790
+ if (primary.type === 'function' || primary.params !== undefined) {
791
+ // Use maxResults to limit file iteration (with buffer for exclude filtering)
792
+ const callerCap = maxCallers === Infinity ? undefined : maxCallers * 3;
793
+ allCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
794
+ // Apply exclude filter before slicing
795
+ if (options.exclude && options.exclude.length > 0) {
796
+ allCallers = allCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
797
+ }
798
+ // Apply confidence filtering before slicing
799
+ if (options.minConfidence > 0) {
800
+ const { filterByConfidence } = require('./confidence');
801
+ const callerResult = filterByConfidence(allCallers, options.minConfidence);
802
+ allCallers = callerResult.kept;
803
+ aboutConfFiltered += callerResult.filtered;
804
+ }
805
+ callers = allCallers.slice(0, maxCallers).map(c => ({
806
+ file: c.relativePath,
807
+ line: c.line,
808
+ expression: c.content.trim(),
809
+ callerName: c.callerName,
810
+ confidence: c.confidence,
811
+ resolution: c.resolution,
812
+ }));
813
+
814
+ allCallees = index.findCallees(primary, { includeMethods, includeUncertain: options.includeUncertain });
815
+ // Apply exclude filter before slicing
816
+ if (options.exclude && options.exclude.length > 0) {
817
+ allCallees = allCallees.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
818
+ }
819
+ // Apply confidence filtering before slicing
820
+ if (options.minConfidence > 0) {
821
+ const { filterByConfidence } = require('./confidence');
822
+ const calleeResult = filterByConfidence(allCallees, options.minConfidence);
823
+ allCallees = calleeResult.kept;
824
+ aboutConfFiltered += calleeResult.filtered;
825
+ }
826
+ callees = allCallees.slice(0, maxCallees).map(c => ({
827
+ name: c.name,
828
+ file: c.relativePath,
829
+ line: c.startLine,
830
+ startLine: c.startLine,
831
+ endLine: c.endLine,
832
+ weight: c.weight,
833
+ callCount: c.callCount,
834
+ confidence: c.confidence,
835
+ resolution: c.resolution,
836
+ }));
837
+ }
838
+
839
+ // Find tests
840
+ const tests = index.tests(symbolName);
841
+ const testSummary = {
842
+ fileCount: tests.length,
843
+ totalMatches: tests.reduce((sum, t) => sum + t.matches.length, 0),
844
+ files: (options.all ? tests : tests.slice(0, 3)).map(t => t.file)
845
+ };
846
+
847
+ // Extract code if requested (default: true)
848
+ let code = null;
849
+ if (options.withCode !== false) {
850
+ code = index.extractCode(primary);
851
+ }
852
+
853
+ // Get type definitions if requested
854
+ let types = [];
855
+ if (options.withTypes) {
856
+ const TYPE_KINDS = ['type', 'interface', 'class', 'struct'];
857
+ const seen = new Set();
858
+
859
+ const addType = (typeName) => {
860
+ if (seen.has(typeName)) return;
861
+ seen.add(typeName);
862
+ const typeSymbols = index.symbols.get(typeName);
863
+ if (typeSymbols) {
864
+ for (const sym of typeSymbols) {
865
+ if (TYPE_KINDS.includes(sym.type)) {
866
+ types.push({
867
+ name: sym.name,
868
+ type: sym.type,
869
+ file: sym.relativePath,
870
+ line: sym.startLine
871
+ });
872
+ }
873
+ }
874
+ }
875
+ };
876
+
877
+ // From signature annotations
878
+ const typeNames = index.extractTypeNames(primary);
879
+ for (const typeName of typeNames) addType(typeName);
880
+
881
+ // From callee signatures — types used by functions this function calls
882
+ if (allCallees) {
883
+ for (const callee of allCallees) {
884
+ const calleeTypeNames = index.extractTypeNames(callee);
885
+ for (const tn of calleeTypeNames) addType(tn);
886
+ }
887
+ }
888
+ }
889
+
890
+ const result = {
891
+ found: true,
892
+ symbol: {
893
+ name: primary.name,
894
+ type: primary.type,
895
+ file: primary.relativePath,
896
+ startLine: primary.startLine,
897
+ endLine: primary.endLine,
898
+ params: primary.params,
899
+ returnType: primary.returnType,
900
+ modifiers: primary.modifiers,
901
+ docstring: primary.docstring,
902
+ signature: index.formatSignature(primary)
903
+ },
904
+ usages: usagesByType,
905
+ totalUsages: usagesByType.calls + usagesByType.imports + usagesByType.references,
906
+ callers: {
907
+ total: allCallers?.length ?? 0,
908
+ top: callers
909
+ },
910
+ callees: {
911
+ total: allCallees?.length ?? 0,
912
+ top: callees
913
+ },
914
+ tests: testSummary,
915
+ otherDefinitions: (options.all ? others : others.slice(0, 3)).map(d => ({
916
+ file: d.relativePath,
917
+ line: d.startLine,
918
+ usageCount: d.usageCount ?? index.countSymbolUsages(d).total
919
+ })),
920
+ types,
921
+ code,
922
+ includeMethods,
923
+ ...(aboutConfFiltered > 0 && { confidenceFiltered: aboutConfFiltered }),
924
+ completeness: detectCompleteness(index)
925
+ };
926
+
927
+ return result;
928
+ } finally { index._endOp(); }
929
+ }
930
+
931
+ /**
932
+ * Diff-based impact analysis: find which functions changed and who calls them.
933
+ *
934
+ * @param {object} index - ProjectIndex instance
935
+ * @param {object} options - { base, staged, file }
936
+ * @returns {object}
937
+ */
938
+ function diffImpact(index, options = {}) {
939
+ index._beginOp();
940
+ try {
941
+ const { base = 'HEAD', staged = false, file } = options;
942
+
943
+ // Validate base ref format to prevent argument injection
944
+ if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) { // eslint-disable-line no-useless-escape
945
+ throw new Error(`Invalid git ref format: ${base}`);
946
+ }
947
+
948
+ // Verify git repo
949
+ let gitRoot;
950
+ try {
951
+ gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: index.root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
952
+ } catch (e) {
953
+ throw new Error('Not a git repository. diff-impact requires git.', { cause: e });
954
+ }
955
+
956
+ // Build git diff command (use execFileSync to avoid shell expansion)
957
+ const diffArgs = ['diff', '--unified=0'];
958
+ if (staged) {
959
+ diffArgs.push('--staged');
960
+ } else {
961
+ diffArgs.push(base);
962
+ }
963
+ if (file) {
964
+ diffArgs.push('--', file);
965
+ }
966
+
967
+ let diffText;
968
+ try {
969
+ diffText = execFileSync('git', diffArgs, { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
970
+ } catch (e) {
971
+ // git diff exits non-zero when there are diff errors, but also for invalid refs
972
+ if (e.stdout) {
973
+ diffText = e.stdout;
974
+ } else {
975
+ throw new Error(`git diff failed: ${e.message}`, { cause: e });
976
+ }
977
+ }
978
+
979
+ if (!diffText || !diffText.trim()) {
980
+ return {
981
+ base: staged ? '(staged)' : base,
982
+ functions: [],
983
+ moduleLevelChanges: [],
984
+ newFunctions: [],
985
+ deletedFunctions: [],
986
+ summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, affectedFiles: 0 }
987
+ };
988
+ }
989
+
990
+ // Diff paths are git-root-relative. Resolve to index.root for file lookup.
991
+ // Normalize both through realpath to handle macOS /var → /private/var symlinks.
992
+ let realGitRoot, realProjectRoot;
993
+ try { realGitRoot = fs.realpathSync(gitRoot); } catch (_) { realGitRoot = gitRoot; }
994
+ try { realProjectRoot = fs.realpathSync(index.root); } catch (_) { realProjectRoot = index.root; }
995
+ const projectPrefix = realGitRoot === realProjectRoot
996
+ ? ''
997
+ : path.relative(realGitRoot, realProjectRoot);
998
+
999
+ const rawChanges = parseDiff(diffText, gitRoot);
1000
+ // Filter to files under index.root and remap paths.
1001
+ // Preserve gitRelativePath (repo-relative) for git show commands.
1002
+ const changes = [];
1003
+ for (const c of rawChanges) {
1004
+ if (projectPrefix && !c.relativePath.startsWith(projectPrefix + '/')) continue;
1005
+ const localRel = projectPrefix ? c.relativePath.slice(projectPrefix.length + 1) : c.relativePath;
1006
+ changes.push({ ...c, gitRelativePath: c.relativePath, filePath: path.join(index.root, localRel), relativePath: localRel });
1007
+ }
1008
+
1009
+ const functions = [];
1010
+ const moduleLevelChanges = [];
1011
+ const newFunctions = [];
1012
+ const deletedFunctions = [];
1013
+ const callerFileSet = new Set();
1014
+ let totalCallSites = 0;
1015
+
1016
+ for (const change of changes) {
1017
+ const lang = detectLanguage(change.filePath);
1018
+ if (!lang) continue;
1019
+
1020
+ const fileEntry = index.files.get(change.filePath);
1021
+
1022
+ // Handle deleted files: entire file was removed, all functions are deleted
1023
+ if (!fileEntry) {
1024
+ if (change.isDeleted && change.deletedLines.length > 0) {
1025
+ const ref = staged ? 'HEAD' : base;
1026
+ try {
1027
+ const oldContent = execFileSync(
1028
+ 'git', ['show', `${ref}:${change.gitRelativePath}`],
1029
+ { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
1030
+ );
1031
+ const oldParsed = parse(oldContent, lang);
1032
+ for (const oldFn of extractCallableSymbols(oldParsed)) {
1033
+ deletedFunctions.push({
1034
+ name: oldFn.name,
1035
+ filePath: change.filePath,
1036
+ relativePath: change.relativePath,
1037
+ startLine: oldFn.startLine
1038
+ });
1039
+ }
1040
+ } catch (e) {
1041
+ // git show failed — skip
1042
+ }
1043
+ }
1044
+ continue;
1045
+ }
1046
+
1047
+ // Track which functions are affected by added/modified lines
1048
+ const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
1049
+
1050
+ for (const line of change.addedLines) {
1051
+ const symbol = index.findEnclosingFunction(change.filePath, line, true);
1052
+ if (symbol) {
1053
+ const key = `${symbol.name}:${symbol.startLine}`;
1054
+ if (!affectedSymbols.has(key)) {
1055
+ affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
1056
+ }
1057
+ affectedSymbols.get(key).addedLines.push(line);
1058
+ } else {
1059
+ // Module-level change
1060
+ const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
1061
+ if (existing) {
1062
+ existing.addedLines.push(line);
1063
+ } else {
1064
+ moduleLevelChanges.push({
1065
+ filePath: change.filePath,
1066
+ relativePath: change.relativePath,
1067
+ addedLines: [line],
1068
+ deletedLines: []
1069
+ });
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ for (const line of change.deletedLines) {
1075
+ // For deleted lines, we can't use findEnclosingFunction on the current file
1076
+ // since those lines no longer exist. Track as module-level unless they map
1077
+ // to a function that still exists (the function was modified, not deleted).
1078
+ // We approximate: if a deleted line is within the range of a known symbol, it's a modification.
1079
+ let matched = false;
1080
+ for (const symbol of fileEntry.symbols) {
1081
+ if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
1082
+ // Use a generous range — deleted lines near a function likely belong to it
1083
+ if (line >= symbol.startLine - 2 && line <= symbol.endLine + 2) {
1084
+ const key = `${symbol.name}:${symbol.startLine}`;
1085
+ if (!affectedSymbols.has(key)) {
1086
+ affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
1087
+ }
1088
+ affectedSymbols.get(key).deletedLines.push(line);
1089
+ matched = true;
1090
+ break;
1091
+ }
1092
+ }
1093
+ if (!matched) {
1094
+ const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
1095
+ if (existing) {
1096
+ existing.deletedLines.push(line);
1097
+ } else {
1098
+ moduleLevelChanges.push({
1099
+ filePath: change.filePath,
1100
+ relativePath: change.relativePath,
1101
+ addedLines: [],
1102
+ deletedLines: [line]
1103
+ });
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ // Detect new functions: all added lines are within a single function range
1109
+ // and the function didn't exist before (approximation: all lines in the function are added)
1110
+ for (const [key, data] of affectedSymbols) {
1111
+ const { symbol, addedLines } = data;
1112
+ const fnLineCount = symbol.endLine - symbol.startLine + 1;
1113
+ if (addedLines.length >= fnLineCount * 0.8 && data.deletedLines.length === 0) {
1114
+ newFunctions.push({
1115
+ name: symbol.name,
1116
+ filePath: change.filePath,
1117
+ relativePath: change.relativePath,
1118
+ startLine: symbol.startLine,
1119
+ endLine: symbol.endLine,
1120
+ signature: index.formatSignature(symbol)
1121
+ });
1122
+ affectedSymbols.delete(key);
1123
+ }
1124
+ }
1125
+
1126
+ // Detect deleted functions: compare old file symbols with current by identity.
1127
+ // Uses name+className counts to handle overloads (e.g. Java method overloading).
1128
+ if (change.deletedLines.length > 0) {
1129
+ const ref = staged ? 'HEAD' : base;
1130
+ try {
1131
+ const oldContent = execFileSync(
1132
+ 'git', ['show', `${ref}:${change.gitRelativePath}`],
1133
+ { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
1134
+ );
1135
+ const fileLang = detectLanguage(change.filePath);
1136
+ if (fileLang) {
1137
+ const oldParsed = parse(oldContent, fileLang);
1138
+ // Count current symbols by identity (name + className)
1139
+ const currentCounts = new Map();
1140
+ for (const s of fileEntry.symbols) {
1141
+ if (NON_CALLABLE_TYPES.has(s.type)) continue;
1142
+ const key = `${s.name}\0${s.className || ''}`;
1143
+ currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
1144
+ }
1145
+ // Count old symbols by identity and detect deletions
1146
+ const oldCounts = new Map();
1147
+ const oldSymbols = extractCallableSymbols(oldParsed);
1148
+ for (const oldFn of oldSymbols) {
1149
+ const key = `${oldFn.name}\0${oldFn.className || ''}`;
1150
+ oldCounts.set(key, (oldCounts.get(key) || 0) + 1);
1151
+ }
1152
+ // For each identity, if old count > current count, the difference are deletions
1153
+ for (const [key, oldCount] of oldCounts) {
1154
+ const curCount = currentCounts.get(key) || 0;
1155
+ if (oldCount > curCount) {
1156
+ // Find the specific old symbols with this identity that were deleted
1157
+ const matching = oldSymbols.filter(s => `${s.name}\0${s.className || ''}` === key);
1158
+ // Report the extra ones (by startLine descending — later ones more likely deleted)
1159
+ const toReport = matching.slice(curCount);
1160
+ for (const oldFn of toReport) {
1161
+ deletedFunctions.push({
1162
+ name: oldFn.name,
1163
+ filePath: change.filePath,
1164
+ relativePath: change.relativePath,
1165
+ startLine: oldFn.startLine
1166
+ });
1167
+ }
1168
+ }
1169
+ }
1170
+ }
1171
+ } catch (e) {
1172
+ // File didn't exist in base, or git error — skip
1173
+ }
1174
+ }
1175
+
1176
+ // For each affected function, find callers
1177
+ for (const [, data] of affectedSymbols) {
1178
+ const { symbol, addedLines: aLines, deletedLines: dLines } = data;
1179
+
1180
+ // Get the specific definitions matching this symbol
1181
+ const allDefs = index.symbols.get(symbol.name) || [];
1182
+ const targetDefs = allDefs.filter(d => d.file === change.filePath && d.startLine === symbol.startLine);
1183
+
1184
+ let callers = index.findCallers(symbol.name, {
1185
+ targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
1186
+ includeMethods: true,
1187
+ includeUncertain: false,
1188
+ });
1189
+
1190
+ // For Go/Java/Rust methods with a className, filter callers whose
1191
+ // receiver clearly belongs to a different type (same logic as impact()).
1192
+ const targetDef = targetDefs[0] || symbol;
1193
+ if (targetDef.className && langTraits(lang)?.typeSystem === 'nominal') {
1194
+ const targetClassName = targetDef.className;
1195
+ // Pre-compute how many types share this method name
1196
+ const methodDefs = index.symbols.get(symbol.name);
1197
+ const classNames = new Set();
1198
+ if (methodDefs) {
1199
+ for (const d of methodDefs) {
1200
+ if (d.className) classNames.add(d.className);
1201
+ else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
1202
+ }
1203
+ }
1204
+ const isWidelyShared = classNames.size > 3;
1205
+ callers = callers.filter(c => {
1206
+ if (!c.isMethod) return true;
1207
+ const r = c.receiver;
1208
+ if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
1209
+ // No receiver (chained/complex expression): only include if method is
1210
+ // unique or rare across types — otherwise too many false positives
1211
+ if (!r) {
1212
+ return classNames.size <= 1;
1213
+ }
1214
+ // Use receiverType from findCallers when available
1215
+ if (c.receiverType) {
1216
+ return c.receiverType === targetClassName ||
1217
+ c.receiverType === targetDef.receiver?.replace(/^\*/, '');
1218
+ }
1219
+ // Unique method heuristic: if the method exists on exactly one class/type, include
1220
+ if (classNames.size === 1 && classNames.has(targetClassName)) return true;
1221
+ // For widely shared method names (Get, Set, Run, etc.), require same-package
1222
+ // evidence when receiver type is unknown
1223
+ if (isWidelyShared) {
1224
+ const callerFile = c.file || '';
1225
+ const targetDir = path.dirname(change.filePath);
1226
+ return path.dirname(callerFile) === targetDir;
1227
+ }
1228
+ // Unknown receiver + multiple classes with this method → filter out
1229
+ return false;
1230
+ });
1231
+ }
1232
+
1233
+ for (const c of callers) {
1234
+ callerFileSet.add(c.file);
1235
+ }
1236
+ totalCallSites += callers.length;
1237
+
1238
+ functions.push({
1239
+ name: symbol.name,
1240
+ filePath: change.filePath,
1241
+ relativePath: change.relativePath,
1242
+ startLine: symbol.startLine,
1243
+ endLine: symbol.endLine,
1244
+ signature: index.formatSignature(symbol),
1245
+ addedLines: aLines,
1246
+ deletedLines: dLines,
1247
+ callers: callers.map(c => ({
1248
+ file: c.file,
1249
+ relativePath: c.relativePath,
1250
+ line: c.line,
1251
+ callerName: c.callerName,
1252
+ content: c.content.trim()
1253
+ }))
1254
+ });
1255
+ }
1256
+ }
1257
+
1258
+ return {
1259
+ base: staged ? '(staged)' : base,
1260
+ functions,
1261
+ moduleLevelChanges,
1262
+ newFunctions,
1263
+ deletedFunctions,
1264
+ summary: {
1265
+ modifiedFunctions: functions.length,
1266
+ deletedFunctions: deletedFunctions.length,
1267
+ newFunctions: newFunctions.length,
1268
+ totalCallSites,
1269
+ affectedFiles: callerFileSet.size
1270
+ }
1271
+ };
1272
+ } finally { index._endOp(); }
1273
+ }
1274
+
1275
+ // ========================================================================
1276
+ // STANDALONE HELPERS (used by diffImpact and parseDiff)
1277
+ // ========================================================================
1278
+
1279
+ /**
1280
+ * Extract all callable symbols (functions + class methods) from a parse result,
1281
+ * matching how indexFile builds the symbol list. Methods get className added.
1282
+ * @param {object} parsed - Result from parse()
1283
+ * @returns {Array<{name, className, startLine}>}
1284
+ */
1285
+ function extractCallableSymbols(parsed) {
1286
+ const symbols = [];
1287
+ for (const fn of parsed.functions) {
1288
+ symbols.push({ name: fn.name, className: fn.className || '', startLine: fn.startLine });
1289
+ }
1290
+ for (const cls of parsed.classes) {
1291
+ if (cls.members) {
1292
+ for (const m of cls.members) {
1293
+ symbols.push({ name: m.name, className: cls.name, startLine: m.startLine });
1294
+ }
1295
+ }
1296
+ }
1297
+ return symbols;
1298
+ }
1299
+
1300
+ /**
1301
+ * Unquote a git diff path: unescape C-style backslash sequences and strip tab metadata.
1302
+ * Git quotes paths containing special chars as "a/path\"with\"quotes".
1303
+ * @param {string} raw - Raw path string (may contain backslash escapes)
1304
+ * @returns {string} Unquoted path
1305
+ */
1306
+ function unquoteDiffPath(raw) {
1307
+ const ESCAPES = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\t': '\t' };
1308
+ return raw
1309
+ .split('\t')[0]
1310
+ .replace(/\\[\\"nt]/g, m => ESCAPES[m]);
1311
+ }
1312
+
1313
+ /**
1314
+ * Parse unified diff output into structured change data
1315
+ * @param {string} diffText - Output from `git diff --unified=0`
1316
+ * @param {string} root - Project root directory
1317
+ * @returns {Array<{ filePath, relativePath, addedLines, deletedLines }>}
1318
+ */
1319
+ function parseDiff(diffText, root) {
1320
+ const changes = [];
1321
+ let currentFile = null;
1322
+ let pendingOldPath = null; // Track --- a/ path for deleted files
1323
+
1324
+ for (const line of diffText.split('\n')) {
1325
+ // Track old file path from --- header for deleted-file detection
1326
+ // Handles both unquoted (--- a/path) and quoted (--- "a/path") formats
1327
+ const oldMatch = line.match(/^--- (?:"a\/((?:[^"\\]|\\.)*)"|a\/(.+?))\s*$/);
1328
+ if (oldMatch) {
1329
+ const raw = oldMatch[1] !== undefined ? oldMatch[1] : oldMatch[2];
1330
+ pendingOldPath = unquoteDiffPath(raw);
1331
+ continue;
1332
+ }
1333
+
1334
+ // Match file header: +++ b/path or +++ "b/path" or +++ /dev/null
1335
+ if (line.startsWith('+++ ')) {
1336
+ let relativePath;
1337
+ const isDevNull = line.startsWith('+++ /dev/null');
1338
+ if (isDevNull) {
1339
+ // File was deleted — use the --- a/ path
1340
+ if (!pendingOldPath) continue;
1341
+ relativePath = pendingOldPath;
1342
+ } else {
1343
+ const newMatch = line.match(/^\+\+\+ (?:"b\/((?:[^"\\]|\\.)*)"|b\/(.+?))\s*$/);
1344
+ if (!newMatch) continue;
1345
+ const raw = newMatch[1] !== undefined ? newMatch[1] : newMatch[2];
1346
+ relativePath = unquoteDiffPath(raw);
1347
+ }
1348
+ pendingOldPath = null;
1349
+ currentFile = {
1350
+ filePath: path.join(root, relativePath),
1351
+ relativePath,
1352
+ addedLines: [],
1353
+ deletedLines: [],
1354
+ ...(isDevNull && { isDeleted: true })
1355
+ };
1356
+ changes.push(currentFile);
1357
+ continue;
1358
+ }
1359
+
1360
+ // Match hunk header: @@ -old,count +new,count @@
1361
+ if (line.startsWith('@@') && currentFile) {
1362
+ const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
1363
+ if (match) {
1364
+ const oldStart = parseInt(match[1], 10);
1365
+ const oldCount = parseInt(match[2] || '1', 10);
1366
+ const newStart = parseInt(match[3], 10);
1367
+ const newCount = parseInt(match[4] || '1', 10);
1368
+
1369
+ // Deleted lines (from old file)
1370
+ if (oldCount > 0) {
1371
+ for (let i = 0; i < oldCount; i++) {
1372
+ currentFile.deletedLines.push(oldStart + i);
1373
+ }
1374
+ }
1375
+
1376
+ // Added lines (in new file)
1377
+ if (newCount > 0) {
1378
+ for (let i = 0; i < newCount; i++) {
1379
+ currentFile.addedLines.push(newStart + i);
1380
+ }
1381
+ }
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ return changes;
1387
+ }
1388
+
1389
+ module.exports = {
1390
+ context,
1391
+ smart,
1392
+ detectCompleteness,
1393
+ related,
1394
+ impact,
1395
+ about,
1396
+ diffImpact,
1397
+ parseDiff,
1398
+ extractCallableSymbols,
1399
+ unquoteDiffPath,
1400
+ };