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.
- package/.claude/skills/ucn/SKILL.md +3 -1
- package/.github/workflows/ci.yml +13 -1
- package/README.md +1 -0
- package/cli/index.js +165 -246
- package/core/analysis.js +1400 -0
- package/core/build-worker.js +194 -0
- package/core/cache.js +105 -7
- package/core/callers.js +194 -64
- package/core/deadcode.js +22 -66
- package/core/discovery.js +9 -54
- package/core/execute.js +139 -54
- package/core/graph.js +615 -0
- package/core/output/analysis-ext.js +271 -0
- package/core/output/analysis.js +491 -0
- package/core/output/extraction.js +188 -0
- package/core/output/find.js +355 -0
- package/core/output/graph.js +399 -0
- package/core/output/refactoring.js +293 -0
- package/core/output/reporting.js +331 -0
- package/core/output/search.js +307 -0
- package/core/output/shared.js +271 -0
- package/core/output/tracing.js +416 -0
- package/core/output.js +15 -3293
- package/core/parallel-build.js +165 -0
- package/core/project.js +299 -3633
- package/core/registry.js +59 -0
- package/core/reporting.js +258 -0
- package/core/search.js +890 -0
- package/core/stacktrace.js +1 -1
- package/core/tracing.js +631 -0
- package/core/verify.js +10 -13
- package/eslint.config.js +43 -0
- package/jsconfig.json +10 -0
- package/languages/go.js +21 -2
- package/languages/html.js +8 -0
- package/languages/index.js +102 -40
- package/languages/java.js +13 -0
- package/languages/javascript.js +17 -1
- package/languages/python.js +14 -0
- package/languages/rust.js +13 -0
- package/languages/utils.js +1 -1
- package/mcp/server.js +45 -28
- package/package.json +8 -3
package/core/stacktrace.js
CHANGED
|
@@ -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+([
|
|
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
|
package/core/tracing.js
ADDED
|
@@ -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 };
|