ucn 3.8.13 → 3.8.14

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 (43) 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/output/analysis-ext.js +271 -0
  14. package/core/output/analysis.js +491 -0
  15. package/core/output/extraction.js +188 -0
  16. package/core/output/find.js +355 -0
  17. package/core/output/graph.js +399 -0
  18. package/core/output/refactoring.js +293 -0
  19. package/core/output/reporting.js +331 -0
  20. package/core/output/search.js +307 -0
  21. package/core/output/shared.js +271 -0
  22. package/core/output/tracing.js +416 -0
  23. package/core/output.js +15 -3293
  24. package/core/parallel-build.js +165 -0
  25. package/core/project.js +299 -3633
  26. package/core/registry.js +59 -0
  27. package/core/reporting.js +258 -0
  28. package/core/search.js +890 -0
  29. package/core/stacktrace.js +1 -1
  30. package/core/tracing.js +631 -0
  31. package/core/verify.js +10 -13
  32. package/eslint.config.js +43 -0
  33. package/jsconfig.json +10 -0
  34. package/languages/go.js +21 -2
  35. package/languages/html.js +8 -0
  36. package/languages/index.js +102 -40
  37. package/languages/java.js +13 -0
  38. package/languages/javascript.js +17 -1
  39. package/languages/python.js +14 -0
  40. package/languages/rust.js +13 -0
  41. package/languages/utils.js +1 -1
  42. package/mcp/server.js +45 -28
  43. package/package.json +8 -3
@@ -296,7 +296,7 @@ function parseStackTrace(index, stackText) {
296
296
  // Also handles method syntax: "package.(*Type).Method(...)"
297
297
  { regex: /^\s*((?:[^\s(]|\([^)]*\))+)\(.*\)$/, extract: null }, // Skip function-only lines
298
298
  // Java: "at package.Class.method(File.java:line)"
299
- { regex: /at\s+([^\(]+)\(([^:]+):(\d+)\)/, extract: (m) => ({ funcName: m[1].split('.').pop(), file: m[2], line: parseInt(m[3]), col: null }) },
299
+ { regex: /at\s+([^(]+)\(([^:]+):(\d+)\)/, extract: (m) => ({ funcName: m[1].split('.').pop(), file: m[2], line: parseInt(m[3]), col: null }) },
300
300
  // Rust: "at src/main.rs:line:col" or panic location
301
301
  { 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 }) },
302
302
  // Generic: "file:line" as last resort
@@ -0,0 +1,631 @@
1
+ /**
2
+ * core/tracing.js — Call chain tracing (trace, blast, reverseTrace, affectedTests)
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 path = require('path');
11
+ const { escapeRegExp } = require('./shared');
12
+ const { isTestFile } = require('./discovery');
13
+
14
+ /**
15
+ * Trace execution flow — build a tree of callees (down), callers (up), or both.
16
+ *
17
+ * @param {object} index - ProjectIndex instance
18
+ * @param {string} name - Function name
19
+ * @param {object} options - { depth, direction, file, className, all, includeMethods, includeUncertain }
20
+ * @returns {object|null} Trace tree with callers/callees
21
+ */
22
+ function trace(index, name, options = {}) {
23
+ index._beginOp();
24
+ try {
25
+ // Sanitize depth: use default for null/undefined, clamp negative to 0
26
+ const rawDepth = options.depth ?? 3;
27
+ const maxDepth = Math.max(0, rawDepth);
28
+ const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
29
+ const maxChildren = options.all ? Infinity : 10;
30
+ // trace defaults to includeMethods=true (execution flow should show method calls)
31
+ const includeMethods = options.includeMethods ?? true;
32
+
33
+ const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className });
34
+ if (!def) {
35
+ return null;
36
+ }
37
+ const visited = new Set();
38
+ // Memoize findCallees/findCallers results within this trace operation.
39
+ // At depth 5, the same function appears at multiple tree positions — without
40
+ // caching, findCallees is called redundantly (O(10^depth) → O(unique functions)).
41
+ const calleeCache = new Map();
42
+ const callerCache = new Map();
43
+
44
+ const buildTree = (funcDef, currentDepth, dir) => {
45
+ const funcName = funcDef.name;
46
+ const key = `${funcDef.file}:${funcDef.startLine}`;
47
+ if (currentDepth > maxDepth) {
48
+ return null;
49
+ }
50
+ if (visited.has(key)) {
51
+ // Already explored — show as leaf node without recursing (prevents infinite loops)
52
+ return {
53
+ name: funcName,
54
+ file: funcDef.relativePath,
55
+ line: funcDef.startLine,
56
+ type: funcDef.type,
57
+ children: [],
58
+ alreadyShown: true
59
+ };
60
+ }
61
+ visited.add(key);
62
+
63
+ const node = {
64
+ name: funcName,
65
+ file: funcDef.relativePath,
66
+ line: funcDef.startLine,
67
+ type: funcDef.type,
68
+ children: []
69
+ };
70
+
71
+ if (dir === 'down' || dir === 'both') {
72
+ let callees = calleeCache.get(key);
73
+ if (!callees) {
74
+ callees = index.findCallees(funcDef, { includeMethods, includeUncertain: options.includeUncertain });
75
+ calleeCache.set(key, callees);
76
+ }
77
+ for (const callee of callees.slice(0, maxChildren)) {
78
+ // callee already has the best-matched definition from findCallees
79
+ const childTree = buildTree(callee, currentDepth + 1, 'down');
80
+ if (childTree) {
81
+ node.children.push({
82
+ ...childTree,
83
+ callCount: callee.callCount,
84
+ weight: callee.weight
85
+ });
86
+ }
87
+ }
88
+ if (callees.length > maxChildren) {
89
+ node.truncatedChildren = callees.length - maxChildren;
90
+ }
91
+ }
92
+
93
+ return node;
94
+ };
95
+
96
+ const tree = buildTree(def, 0, direction);
97
+
98
+ // Also get callers if direction is 'up' or 'both'
99
+ let callers = [];
100
+ let truncatedCallers = 0;
101
+ if (direction === 'up' || direction === 'both') {
102
+ const allCallers = index.findCallers(name, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [def] });
103
+ callers = allCallers.slice(0, maxChildren).map(c => ({
104
+ name: c.callerName || '(anonymous)',
105
+ file: c.relativePath,
106
+ line: c.line,
107
+ expression: c.content.trim()
108
+ }));
109
+ if (allCallers.length > maxChildren) {
110
+ truncatedCallers = allCallers.length - maxChildren;
111
+ }
112
+ }
113
+
114
+ // Add smart hint when resolved function has zero callees
115
+ if (tree && tree.children && tree.children.length === 0) {
116
+ if (maxDepth === 0) {
117
+ warnings.push({
118
+ message: `depth=0: showing root function only. Increase depth to see callees.`
119
+ });
120
+ } else if (definitions.length > 1 && !options.file) {
121
+ warnings.push({
122
+ message: `Resolved to ${def.relativePath}:${def.startLine} which has no callees. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
123
+ });
124
+ }
125
+ }
126
+
127
+ return {
128
+ root: name,
129
+ file: def.relativePath,
130
+ line: def.startLine,
131
+ direction,
132
+ maxDepth,
133
+ includeMethods,
134
+ tree,
135
+ callers: direction !== 'down' ? callers : undefined,
136
+ truncatedCallers: truncatedCallers > 0 ? truncatedCallers : undefined,
137
+ warnings: warnings.length > 0 ? warnings : undefined
138
+ };
139
+ } finally { index._endOp(); }
140
+ }
141
+
142
+ /**
143
+ * Blast radius — transitive caller tree.
144
+ * Answers: "What breaks transitively if I change this function?"
145
+ *
146
+ * @param {object} index - ProjectIndex instance
147
+ * @param {string} name - Function name
148
+ * @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
149
+ * @returns {object|null} Blast radius tree with summary
150
+ */
151
+ function blast(index, name, options = {}) {
152
+ index._beginOp();
153
+ try {
154
+ const maxDepth = Math.max(0, options.depth ?? 3);
155
+ const maxChildren = options.all ? Infinity : 10;
156
+ const includeMethods = options.includeMethods ?? true;
157
+ const includeUncertain = options.includeUncertain || false;
158
+ const exclude = options.exclude || [];
159
+
160
+ const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className });
161
+ if (!def) return null;
162
+
163
+ const visited = new Set();
164
+ const callerCache = new Map();
165
+ const affectedFunctions = new Set();
166
+ const affectedFiles = new Set();
167
+ let maxDepthReached = 0;
168
+
169
+ const buildCallerTree = (funcDef, currentDepth) => {
170
+ const key = `${funcDef.file}:${funcDef.startLine}`;
171
+ if (currentDepth > maxDepth) return null;
172
+ if (visited.has(key)) {
173
+ return {
174
+ name: funcDef.name,
175
+ file: funcDef.relativePath,
176
+ line: funcDef.startLine,
177
+ type: funcDef.type || 'function',
178
+ children: [],
179
+ alreadyShown: true
180
+ };
181
+ }
182
+ visited.add(key);
183
+
184
+ if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
185
+ if (currentDepth > 0) {
186
+ affectedFunctions.add(key);
187
+ affectedFiles.add(funcDef.file);
188
+ }
189
+
190
+ const node = {
191
+ name: funcDef.name,
192
+ file: funcDef.relativePath,
193
+ line: funcDef.startLine,
194
+ type: funcDef.type || 'function',
195
+ children: []
196
+ };
197
+
198
+ if (currentDepth < maxDepth) {
199
+ const callerCacheKey = funcDef.bindingId
200
+ ? `${funcDef.name}:${funcDef.bindingId}`
201
+ : `${funcDef.name}:${key}`;
202
+ let callers = callerCache.get(callerCacheKey);
203
+ if (!callers) {
204
+ callers = index.findCallers(funcDef.name, {
205
+ includeMethods,
206
+ includeUncertain,
207
+ targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
208
+ });
209
+ callerCache.set(callerCacheKey, callers);
210
+ }
211
+
212
+ // Deduplicate callers by enclosing function (multiple call sites → one tree node)
213
+ const uniqueCallers = new Map();
214
+ for (const c of callers) {
215
+ if (!c.callerName) continue; // skip module-level code
216
+ // Apply exclude filter
217
+ if (exclude.length > 0 && !index.matchesFilters(c.relativePath, { exclude })) continue;
218
+ const callerKey = c.callerStartLine
219
+ ? `${c.callerFile}:${c.callerStartLine}`
220
+ : `${c.callerFile}:${c.callerName}`;
221
+ if (!uniqueCallers.has(callerKey)) {
222
+ uniqueCallers.set(callerKey, {
223
+ name: c.callerName,
224
+ file: c.callerFile,
225
+ relativePath: c.relativePath,
226
+ startLine: c.callerStartLine,
227
+ endLine: c.callerEndLine,
228
+ callSites: 1
229
+ });
230
+ } else {
231
+ uniqueCallers.get(callerKey).callSites++;
232
+ }
233
+ }
234
+
235
+ // Resolve definitions and build child nodes
236
+ const callerEntries = [];
237
+ for (const [, caller] of uniqueCallers) {
238
+ // Look up actual definition from symbol table
239
+ const defs = index.symbols.get(caller.name);
240
+ let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
241
+
242
+ if (!callerDef) {
243
+ // Pseudo-definition for callers not in symbol table
244
+ callerDef = {
245
+ name: caller.name,
246
+ file: caller.file,
247
+ relativePath: caller.relativePath,
248
+ startLine: caller.startLine,
249
+ endLine: caller.endLine,
250
+ type: 'function'
251
+ };
252
+ }
253
+
254
+ callerEntries.push({ def: callerDef, callSites: caller.callSites });
255
+ }
256
+
257
+ // Stable sort by file + line
258
+ callerEntries.sort((a, b) =>
259
+ a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
260
+ );
261
+
262
+ for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
263
+ const childTree = buildCallerTree(cDef, currentDepth + 1);
264
+ if (childTree) {
265
+ childTree.callSites = callSites;
266
+ node.children.push(childTree);
267
+ }
268
+ }
269
+
270
+ if (callerEntries.length > maxChildren) {
271
+ node.truncatedChildren = callerEntries.length - maxChildren;
272
+ // Count truncated callers in summary
273
+ for (const { def: cDef } of callerEntries.slice(maxChildren)) {
274
+ const key = `${cDef.file}:${cDef.startLine}`;
275
+ if (!visited.has(key)) {
276
+ affectedFunctions.add(key);
277
+ affectedFiles.add(cDef.file);
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ return node;
284
+ };
285
+
286
+ const tree = buildCallerTree(def, 0);
287
+
288
+ // Smart hints
289
+ if (tree && tree.children.length === 0) {
290
+ if (maxDepth === 0) {
291
+ warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
292
+ } else if (definitions.length > 1 && !options.file) {
293
+ warnings.push({
294
+ message: `Resolved to ${def.relativePath}:${def.startLine} which has no callers. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
295
+ });
296
+ }
297
+ }
298
+
299
+ return {
300
+ root: name,
301
+ file: def.relativePath,
302
+ line: def.startLine,
303
+ maxDepth,
304
+ includeMethods,
305
+ tree,
306
+ summary: {
307
+ totalAffected: affectedFunctions.size,
308
+ totalFiles: affectedFiles.size,
309
+ maxDepthReached
310
+ },
311
+ warnings: warnings.length > 0 ? warnings : undefined
312
+ };
313
+ } finally { index._endOp(); }
314
+ }
315
+
316
+ /**
317
+ * Reverse trace: walk UP the caller chain to entry points.
318
+ * Like blast but focused on "how does execution reach this function?"
319
+ * Marks leaf nodes (functions with no callers) as entry points.
320
+ *
321
+ * @param {object} index - ProjectIndex instance
322
+ * @param {string} name - Function name
323
+ * @param {object} options - { depth, file, className, all, exclude, includeMethods, includeUncertain }
324
+ * @returns {object|null} Reverse trace tree with entry points
325
+ */
326
+ function reverseTrace(index, name, options = {}) {
327
+ index._beginOp();
328
+ try {
329
+ const maxDepth = Math.max(0, options.depth ?? 5);
330
+ const maxChildren = options.all ? Infinity : 10;
331
+ const includeMethods = options.includeMethods ?? true;
332
+ const includeUncertain = options.includeUncertain || false;
333
+ const exclude = options.exclude || [];
334
+
335
+ const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className });
336
+ if (!def) return null;
337
+
338
+ const visited = new Set();
339
+ const callerCache = new Map();
340
+ const entryPoints = [];
341
+ let maxDepthReached = 0;
342
+
343
+ const buildCallerTree = (funcDef, currentDepth) => {
344
+ const key = `${funcDef.file}:${funcDef.startLine}`;
345
+ if (currentDepth > maxDepth) return null;
346
+ if (visited.has(key)) {
347
+ return {
348
+ name: funcDef.name,
349
+ file: funcDef.relativePath,
350
+ line: funcDef.startLine,
351
+ type: funcDef.type || 'function',
352
+ children: [],
353
+ alreadyShown: true
354
+ };
355
+ }
356
+ visited.add(key);
357
+ if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
358
+
359
+ const node = {
360
+ name: funcDef.name,
361
+ file: funcDef.relativePath,
362
+ line: funcDef.startLine,
363
+ type: funcDef.type || 'function',
364
+ children: []
365
+ };
366
+
367
+ if (currentDepth < maxDepth) {
368
+ const callerCacheKey = funcDef.bindingId
369
+ ? `${funcDef.name}:${funcDef.bindingId}`
370
+ : `${funcDef.name}:${key}`;
371
+ let callers = callerCache.get(callerCacheKey);
372
+ if (!callers) {
373
+ callers = index.findCallers(funcDef.name, {
374
+ includeMethods,
375
+ includeUncertain,
376
+ targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
377
+ });
378
+ callerCache.set(callerCacheKey, callers);
379
+ }
380
+
381
+ // Deduplicate callers by enclosing function
382
+ const uniqueCallers = new Map();
383
+ for (const c of callers) {
384
+ if (!c.callerName) continue;
385
+ if (exclude.length > 0 && !index.matchesFilters(c.relativePath, { exclude })) continue;
386
+ const callerKey = c.callerStartLine
387
+ ? `${c.callerFile}:${c.callerStartLine}`
388
+ : `${c.callerFile}:${c.callerName}`;
389
+ if (!uniqueCallers.has(callerKey)) {
390
+ uniqueCallers.set(callerKey, {
391
+ name: c.callerName,
392
+ file: c.callerFile,
393
+ relativePath: c.relativePath,
394
+ startLine: c.callerStartLine,
395
+ endLine: c.callerEndLine,
396
+ callSites: 1
397
+ });
398
+ } else {
399
+ uniqueCallers.get(callerKey).callSites++;
400
+ }
401
+ }
402
+
403
+ // Resolve definitions and build child nodes
404
+ const callerEntries = [];
405
+ for (const [, caller] of uniqueCallers) {
406
+ const defs = index.symbols.get(caller.name);
407
+ let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
408
+ if (!callerDef) {
409
+ callerDef = {
410
+ name: caller.name,
411
+ file: caller.file,
412
+ relativePath: caller.relativePath,
413
+ startLine: caller.startLine,
414
+ endLine: caller.endLine,
415
+ type: 'function'
416
+ };
417
+ }
418
+ callerEntries.push({ def: callerDef, callSites: caller.callSites });
419
+ }
420
+
421
+ callerEntries.sort((a, b) =>
422
+ a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
423
+ );
424
+
425
+ for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
426
+ const childTree = buildCallerTree(cDef, currentDepth + 1);
427
+ if (childTree) {
428
+ childTree.callSites = callSites;
429
+ node.children.push(childTree);
430
+ }
431
+ }
432
+
433
+ if (callerEntries.length > maxChildren) {
434
+ node.truncatedChildren = callerEntries.length - maxChildren;
435
+ // Count entry points in truncated branches so summary is accurate
436
+ for (const { def: cDef } of callerEntries.slice(maxChildren)) {
437
+ const key = `${cDef.file}:${cDef.startLine}`;
438
+ if (!visited.has(key)) {
439
+ const cCallers = index.findCallers(cDef.name, {
440
+ includeMethods, includeUncertain,
441
+ targetDefinitions: cDef.bindingId ? [cDef] : undefined,
442
+ });
443
+ if (cCallers.length === 0) {
444
+ entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(index.root, cDef.file), line: cDef.startLine });
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ // Mark as entry point if no callers found (and not at depth limit)
451
+ if (uniqueCallers.size === 0 && currentDepth > 0) {
452
+ node.entryPoint = true;
453
+ entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
454
+ }
455
+ } else if (currentDepth > 0) {
456
+ // At depth limit: check if this node is an entry point
457
+ const callers = index.findCallers(funcDef.name, {
458
+ includeMethods,
459
+ includeUncertain,
460
+ targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
461
+ });
462
+ const hasCallers = callers.some(c => c.callerName &&
463
+ (exclude.length === 0 || index.matchesFilters(c.relativePath, { exclude })));
464
+ if (!hasCallers) {
465
+ node.entryPoint = true;
466
+ entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
467
+ }
468
+ }
469
+
470
+ return node;
471
+ };
472
+
473
+ const tree = buildCallerTree(def, 0);
474
+
475
+ // Also mark root as entry point if it has no callers
476
+ if (tree && tree.children.length === 0 && maxDepth > 0) {
477
+ tree.entryPoint = true;
478
+ entryPoints.push({ name: def.name, file: def.relativePath, line: def.startLine });
479
+ }
480
+
481
+ // Smart hints
482
+ if (tree && tree.children.length === 0) {
483
+ if (maxDepth === 0) {
484
+ warnings.push({ message: 'depth=0: showing root function only. Increase depth to see callers.' });
485
+ } else if (definitions.length > 1 && !options.file) {
486
+ warnings.push({
487
+ message: `Resolved to ${def.relativePath}:${def.startLine} which has no callers. ${definitions.length - 1} other definition(s) exist — specify a file to pick a different one.`
488
+ });
489
+ }
490
+ }
491
+
492
+ return {
493
+ root: name,
494
+ file: def.relativePath,
495
+ line: def.startLine,
496
+ maxDepth,
497
+ includeMethods,
498
+ tree,
499
+ entryPoints,
500
+ summary: {
501
+ totalEntryPoints: entryPoints.length,
502
+ totalFunctions: visited.size - 1, // exclude root
503
+ maxDepthReached
504
+ },
505
+ warnings: warnings.length > 0 ? warnings : undefined
506
+ };
507
+ } finally { index._endOp(); }
508
+ }
509
+
510
+ /**
511
+ * Find tests affected by a change to the given function.
512
+ * Composes blast() (transitive callers) with test file scanning.
513
+ *
514
+ * @param {object} index - ProjectIndex instance
515
+ * @param {string} name - Function name
516
+ * @param {object} options - { depth, file, className, exclude, includeMethods, includeUncertain }
517
+ * @returns {object|null} Affected test files with coverage stats
518
+ */
519
+ function affectedTests(index, name, options = {}) {
520
+ index._beginOp();
521
+ try {
522
+ // Step 1: Get all transitively affected functions via blast
523
+ const blastResult = index.blast(name, {
524
+ depth: options.depth ?? 3,
525
+ file: options.file,
526
+ className: options.className,
527
+ all: true,
528
+ exclude: options.exclude,
529
+ includeMethods: options.includeMethods,
530
+ includeUncertain: options.includeUncertain,
531
+ });
532
+ if (!blastResult) return null;
533
+
534
+ // Step 2: Collect all affected function names from the tree
535
+ const affectedNames = new Set();
536
+ affectedNames.add(name);
537
+ const collectNames = (node) => {
538
+ if (!node) return;
539
+ affectedNames.add(node.name);
540
+ for (const child of node.children || []) collectNames(child);
541
+ };
542
+ collectNames(blastResult.tree);
543
+
544
+ // Step 3: Build regex patterns for all names
545
+ const namePatterns = new Map();
546
+ for (const n of affectedNames) {
547
+ const escaped = escapeRegExp(n);
548
+ namePatterns.set(n, {
549
+ regex: new RegExp('\\b' + escaped + '\\b'),
550
+ callPattern: new RegExp(escaped + '\\s*\\('),
551
+ });
552
+ }
553
+
554
+ // Step 4: Scan test files once for all affected names
555
+ const exclude = options.exclude;
556
+ const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
557
+ const results = [];
558
+ for (const [filePath, fileEntry] of index.files) {
559
+ let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
560
+ // Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
561
+ if (!isTest && fileEntry.language === 'rust') {
562
+ isTest = fileEntry.symbols?.some(s => s.modifiers?.includes('test'));
563
+ }
564
+ if (!isTest) continue;
565
+ if (excludeArr.length > 0 && !index.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
566
+ try {
567
+ const content = index._readFile(filePath);
568
+ const lines = content.split('\n');
569
+ const fileMatches = new Map();
570
+
571
+ lines.forEach((line, idx) => {
572
+ for (const [funcName, patterns] of namePatterns) {
573
+ if (patterns.regex.test(line)) {
574
+ let matchType = 'reference';
575
+ if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
576
+ matchType = 'test-case';
577
+ } else if (/\b(import|require|from)\b/.test(line)) {
578
+ matchType = 'import';
579
+ } else if (patterns.callPattern.test(line)) {
580
+ matchType = 'call';
581
+ }
582
+ if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
583
+ fileMatches.get(funcName).push({
584
+ line: idx + 1, content: line.trim(),
585
+ matchType, functionName: funcName
586
+ });
587
+ }
588
+ }
589
+ });
590
+
591
+ if (fileMatches.size > 0) {
592
+ const coveredFunctions = [...fileMatches.keys()];
593
+ const allMatches = [];
594
+ for (const matches of fileMatches.values()) allMatches.push(...matches);
595
+ allMatches.sort((a, b) => a.line - b.line);
596
+ results.push({
597
+ file: fileEntry.relativePath,
598
+ coveredFunctions,
599
+ matchCount: allMatches.length,
600
+ matches: allMatches
601
+ });
602
+ }
603
+ } catch (e) { /* skip unreadable */ }
604
+ }
605
+
606
+ // Sort by coverage breadth then alphabetically
607
+ results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
608
+
609
+ // Compute coverage stats
610
+ const coveredSet = new Set();
611
+ for (const r of results) for (const f of r.coveredFunctions) coveredSet.add(f);
612
+ const uncovered = [...affectedNames].filter(n => !coveredSet.has(n));
613
+
614
+ return {
615
+ root: blastResult.root, file: blastResult.file, line: blastResult.line,
616
+ depth: blastResult.maxDepth,
617
+ affectedFunctions: [...affectedNames],
618
+ testFiles: results,
619
+ summary: {
620
+ totalAffected: affectedNames.size,
621
+ totalTestFiles: results.length,
622
+ coveredFunctions: coveredSet.size,
623
+ uncoveredCount: uncovered.length,
624
+ },
625
+ uncovered,
626
+ warnings: blastResult.warnings,
627
+ };
628
+ } finally { index._endOp(); }
629
+ }
630
+
631
+ module.exports = { trace, blast, reverseTrace, affectedTests };