ucn 3.8.26 → 4.0.1
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 +31 -17
- package/README.md +95 -28
- package/cli/index.js +32 -7
- package/core/account.js +354 -0
- package/core/analysis.js +335 -15
- package/core/build-worker.js +21 -1
- package/core/cache.js +52 -3
- package/core/callers.js +3421 -159
- package/core/confidence.js +82 -19
- package/core/deadcode.js +211 -21
- package/core/execute.js +6 -1
- package/core/graph-build.js +45 -3
- package/core/imports.js +118 -1
- package/core/output/analysis.js +345 -83
- package/core/output/reporting.js +19 -3
- package/core/output/shared.js +33 -2
- package/core/output/tracing.js +208 -10
- package/core/project.js +19 -2
- package/core/registry.js +15 -3
- package/core/shared.js +21 -0
- package/core/tracing.js +534 -190
- package/languages/go.js +317 -6
- package/languages/index.js +79 -0
- package/languages/java.js +243 -16
- package/languages/javascript.js +357 -24
- package/languages/python.js +423 -28
- package/languages/rust.js +377 -8
- package/languages/utils.js +72 -18
- package/mcp/server.js +5 -4
- package/package.json +9 -3
- package/.github/workflows/ci.yml +0 -45
- package/.github/workflows/publish.yml +0 -79
package/core/tracing.js
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extracted from project.js. All functions take an `index` (ProjectIndex)
|
|
5
5
|
* as the first argument instead of using `this`.
|
|
6
|
+
*
|
|
7
|
+
* Tiered tree contract (v4): every command here runs findCallers/findCallees
|
|
8
|
+
* in collectAccount mode. Confirmed-tier edges form the tree trunk; unverified
|
|
9
|
+
* candidates are VISIBLE — caller-direction edges collect into a global
|
|
10
|
+
* `unverifiedFrontier` (with parent-node attribution and a reason), callee
|
|
11
|
+
* unknowns attach to their node as `unverifiedCallees`. Unverified edges are
|
|
12
|
+
* not expanded by default (expanding a possible-dispatch edge would assert
|
|
13
|
+
* transitive reach the evidence doesn't support); `expandUnverified` follows
|
|
14
|
+
* them, marking every downstream node `chainUnverified`. The root hop carries
|
|
15
|
+
* the same text-ground account as context/impact (composeAccount); interior
|
|
16
|
+
* hops conserve over the engine-candidate set, rolled up in `treeAccount`.
|
|
17
|
+
* `includeUncertain` is an implied no-op (the caller-contract precedent).
|
|
6
18
|
*/
|
|
7
19
|
|
|
8
20
|
'use strict';
|
|
@@ -13,12 +25,118 @@ const { isTestFile } = require('./discovery');
|
|
|
13
25
|
const { getCachedCalls } = require('./callers');
|
|
14
26
|
const { detectLanguage, getLanguageModule } = require('../languages');
|
|
15
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Contract-mode caller expansion for the tree commands. Memoizes the full
|
|
30
|
+
* findCallers(collectAccount) result per node and returns the tier partition.
|
|
31
|
+
* Unverified entries are fully enriched (content + enclosing caller): the
|
|
32
|
+
* frontier display and the affected-tests possible closure need them all.
|
|
33
|
+
*/
|
|
34
|
+
function _contractCallers(index, funcDef, { includeMethods, callerCache, pin }) {
|
|
35
|
+
const nodeKey = `${funcDef.file}:${funcDef.startLine}`;
|
|
36
|
+
const cacheKey = funcDef.bindingId
|
|
37
|
+
? `${funcDef.name}:${funcDef.bindingId}`
|
|
38
|
+
: `${funcDef.name}:${nodeKey}`;
|
|
39
|
+
let res = callerCache.get(cacheKey);
|
|
40
|
+
if (!res) {
|
|
41
|
+
const raw = index.findCallers(funcDef.name, {
|
|
42
|
+
includeMethods,
|
|
43
|
+
collectAccount: true,
|
|
44
|
+
unverifiedEnrichLimit: Infinity,
|
|
45
|
+
targetDefinitions: (funcDef.bindingId || pin) ? [funcDef] : undefined,
|
|
46
|
+
});
|
|
47
|
+
res = {
|
|
48
|
+
confirmed: raw.filter(c => c.tier !== 'unverified'),
|
|
49
|
+
unverified: [
|
|
50
|
+
...raw.filter(c => c.tier === 'unverified'),
|
|
51
|
+
...(raw.unverifiedEntries || []),
|
|
52
|
+
],
|
|
53
|
+
raw,
|
|
54
|
+
};
|
|
55
|
+
callerCache.set(cacheKey, res);
|
|
56
|
+
}
|
|
57
|
+
return res;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stable frontier ordering: hop, then parent node, then call site. */
|
|
61
|
+
function _sortFrontier(frontier) {
|
|
62
|
+
frontier.sort((a, b) =>
|
|
63
|
+
(a.hop - b.hop) ||
|
|
64
|
+
(a.atNode.file || '').localeCompare(b.atNode.file || '') ||
|
|
65
|
+
((a.atNode.line || 0) - (b.atNode.line || 0)) ||
|
|
66
|
+
(a.relativePath || '').localeCompare(b.relativePath || '') ||
|
|
67
|
+
((a.line || 0) - (b.line || 0)));
|
|
68
|
+
return frontier;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Aggregate one node's excluded-with-reason candidates into the tree account. */
|
|
72
|
+
function _aggregateExcluded(treeAccount, raw) {
|
|
73
|
+
const entries = (raw.accountRaw && raw.accountRaw.excludedEntries) || [];
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
const r = e.reason || 'excluded';
|
|
76
|
+
treeAccount.excludedTotal++;
|
|
77
|
+
treeAccount.excludedByReason[r] = (treeAccount.excludedByReason[r] || 0) + 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Dedupe caller call-sites into enclosing-function entries and resolve each
|
|
83
|
+
* to its symbol-table definition (pseudo-definition when absent). Shared by
|
|
84
|
+
* the confirmed trunk and the expand-unverified path.
|
|
85
|
+
*/
|
|
86
|
+
function _resolveCallerEntries(index, callers, exclude) {
|
|
87
|
+
const uniqueCallers = new Map();
|
|
88
|
+
for (const c of callers) {
|
|
89
|
+
if (!c.callerName) continue; // skip module-level code
|
|
90
|
+
if (exclude.length > 0 && !index.matchesFilters(c.relativePath, { exclude })) continue;
|
|
91
|
+
const callerKey = c.callerStartLine
|
|
92
|
+
? `${c.callerFile}:${c.callerStartLine}`
|
|
93
|
+
: `${c.callerFile}:${c.callerName}`;
|
|
94
|
+
if (!uniqueCallers.has(callerKey)) {
|
|
95
|
+
uniqueCallers.set(callerKey, {
|
|
96
|
+
name: c.callerName,
|
|
97
|
+
file: c.callerFile,
|
|
98
|
+
relativePath: c.relativePath,
|
|
99
|
+
startLine: c.callerStartLine,
|
|
100
|
+
endLine: c.callerEndLine,
|
|
101
|
+
callSites: 1,
|
|
102
|
+
reason: c.reason,
|
|
103
|
+
});
|
|
104
|
+
} else {
|
|
105
|
+
uniqueCallers.get(callerKey).callSites++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const callerEntries = [];
|
|
110
|
+
for (const [, caller] of uniqueCallers) {
|
|
111
|
+
const defs = index.symbols.get(caller.name);
|
|
112
|
+
let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
|
|
113
|
+
if (!callerDef) {
|
|
114
|
+
// Pseudo-definition for callers not in symbol table
|
|
115
|
+
callerDef = {
|
|
116
|
+
name: caller.name,
|
|
117
|
+
file: caller.file,
|
|
118
|
+
relativePath: caller.relativePath,
|
|
119
|
+
startLine: caller.startLine,
|
|
120
|
+
endLine: caller.endLine,
|
|
121
|
+
type: 'function'
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
callerEntries.push({ def: callerDef, callSites: caller.callSites, reason: caller.reason });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Stable sort by file + line
|
|
128
|
+
callerEntries.sort((a, b) =>
|
|
129
|
+
a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
|
|
130
|
+
);
|
|
131
|
+
return callerEntries;
|
|
132
|
+
}
|
|
133
|
+
|
|
16
134
|
/**
|
|
17
135
|
* Trace execution flow — build a tree of callees (down), callers (up), or both.
|
|
18
136
|
*
|
|
19
137
|
* @param {object} index - ProjectIndex instance
|
|
20
138
|
* @param {string} name - Function name
|
|
21
|
-
* @param {object} options - { depth, direction, file, className, all, includeMethods,
|
|
139
|
+
* @param {object} options - { depth, direction, file, className, all, includeMethods, expandUnverified }
|
|
22
140
|
* @returns {object|null} Trace tree with callers/callees
|
|
23
141
|
*/
|
|
24
142
|
function trace(index, name, options = {}) {
|
|
@@ -37,11 +155,18 @@ function trace(index, name, options = {}) {
|
|
|
37
155
|
return null;
|
|
38
156
|
}
|
|
39
157
|
const visited = new Set();
|
|
40
|
-
// Memoize findCallees
|
|
158
|
+
// Memoize findCallees results within this trace operation.
|
|
41
159
|
// At depth 5, the same function appears at multiple tree positions — without
|
|
42
160
|
// caching, findCallees is called redundantly (O(10^depth) → O(unique functions)).
|
|
43
161
|
const calleeCache = new Map();
|
|
44
|
-
|
|
162
|
+
|
|
163
|
+
// Down-direction conservation rollup: every call site at every expanded
|
|
164
|
+
// node lands in exactly one bucket (per-node calleeAccount, summed here).
|
|
165
|
+
const downAccount = (direction === 'down' || direction === 'both') ? {
|
|
166
|
+
nodesExpanded: 0,
|
|
167
|
+
callSites: { total: 0, confirmed: 0, unverified: 0, external: 0, excluded: 0, filtered: 0 },
|
|
168
|
+
unverifiedByReason: {},
|
|
169
|
+
} : null;
|
|
45
170
|
|
|
46
171
|
const buildTree = (funcDef, currentDepth, dir) => {
|
|
47
172
|
const funcName = funcDef.name;
|
|
@@ -73,9 +198,30 @@ function trace(index, name, options = {}) {
|
|
|
73
198
|
if (dir === 'down' || dir === 'both') {
|
|
74
199
|
let callees = calleeCache.get(key);
|
|
75
200
|
if (!callees) {
|
|
76
|
-
callees = index.findCallees(funcDef, { includeMethods,
|
|
201
|
+
callees = index.findCallees(funcDef, { includeMethods, collectAccount: true });
|
|
77
202
|
calleeCache.set(key, callees);
|
|
78
203
|
}
|
|
204
|
+
// Callee contract: per-node account + visible unverified entries.
|
|
205
|
+
const acct = callees.calleeAccount;
|
|
206
|
+
if (acct && downAccount) {
|
|
207
|
+
node.calleeAccount = acct;
|
|
208
|
+
downAccount.nodesExpanded++;
|
|
209
|
+
downAccount.callSites.total += acct.totalSites;
|
|
210
|
+
downAccount.callSites.confirmed += acct.confirmed;
|
|
211
|
+
downAccount.callSites.unverified += acct.unverified;
|
|
212
|
+
downAccount.callSites.external += acct.external.count;
|
|
213
|
+
downAccount.callSites.excluded += acct.excluded.total;
|
|
214
|
+
downAccount.callSites.filtered += acct.filtered.count;
|
|
215
|
+
}
|
|
216
|
+
if (callees.unverifiedCallees && callees.unverifiedCallees.length > 0) {
|
|
217
|
+
node.unverifiedCallees = callees.unverifiedCallees;
|
|
218
|
+
if (downAccount) {
|
|
219
|
+
for (const u of node.unverifiedCallees) {
|
|
220
|
+
downAccount.unverifiedByReason[u.reason] =
|
|
221
|
+
(downAccount.unverifiedByReason[u.reason] || 0) + u.callCount;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
79
225
|
for (const callee of callees.slice(0, maxChildren)) {
|
|
80
226
|
// callee already has the best-matched definition from findCallees
|
|
81
227
|
const childTree = buildTree(callee, currentDepth + 1, 'down');
|
|
@@ -97,19 +243,42 @@ function trace(index, name, options = {}) {
|
|
|
97
243
|
|
|
98
244
|
const tree = buildTree(def, 0, direction);
|
|
99
245
|
|
|
100
|
-
// Also get callers if direction is 'up' or 'both'
|
|
246
|
+
// Also get callers if direction is 'up' or 'both' — one contract hop:
|
|
247
|
+
// confirmed callers render in CALLED BY, unverified candidates in the
|
|
248
|
+
// frontier, reconciled by the root text-ground account.
|
|
101
249
|
let callers = [];
|
|
102
250
|
let truncatedCallers = 0;
|
|
251
|
+
let unverifiedFrontier;
|
|
252
|
+
let account;
|
|
103
253
|
if (direction === 'up' || direction === 'both') {
|
|
104
|
-
const
|
|
105
|
-
|
|
254
|
+
const rawCallers = index.findCallers(name, {
|
|
255
|
+
includeMethods,
|
|
256
|
+
collectAccount: true,
|
|
257
|
+
unverifiedEnrichLimit: Infinity,
|
|
258
|
+
targetDefinitions: [def],
|
|
259
|
+
});
|
|
260
|
+
const confirmed = rawCallers.filter(c => c.tier !== 'unverified');
|
|
261
|
+
let unverified = [
|
|
262
|
+
...rawCallers.filter(c => c.tier === 'unverified'),
|
|
263
|
+
...(rawCallers.unverifiedEntries || []),
|
|
264
|
+
];
|
|
265
|
+
const { composeAccount, callNotResolvedEntries } = require('./analysis');
|
|
266
|
+
account = composeAccount(index, name, rawCallers);
|
|
267
|
+
unverified = [...unverified, ...callNotResolvedEntries(index, account, options)];
|
|
268
|
+
const rootRef = { name: def.name, file: def.relativePath, line: def.startLine };
|
|
269
|
+
unverifiedFrontier = _sortFrontier(unverified.map(u => ({
|
|
270
|
+
atNode: rootRef,
|
|
271
|
+
hop: 1,
|
|
272
|
+
...u,
|
|
273
|
+
})));
|
|
274
|
+
callers = confirmed.slice(0, maxChildren).map(c => ({
|
|
106
275
|
name: c.callerName || '(anonymous)',
|
|
107
276
|
file: c.relativePath,
|
|
108
277
|
line: c.line,
|
|
109
|
-
expression: c.content.trim()
|
|
278
|
+
expression: (c.content || '').trim()
|
|
110
279
|
}));
|
|
111
|
-
if (
|
|
112
|
-
truncatedCallers =
|
|
280
|
+
if (confirmed.length > maxChildren) {
|
|
281
|
+
truncatedCallers = confirmed.length - maxChildren;
|
|
113
282
|
}
|
|
114
283
|
}
|
|
115
284
|
|
|
@@ -136,6 +305,9 @@ function trace(index, name, options = {}) {
|
|
|
136
305
|
tree,
|
|
137
306
|
callers: direction !== 'down' ? callers : undefined,
|
|
138
307
|
truncatedCallers: truncatedCallers > 0 ? truncatedCallers : undefined,
|
|
308
|
+
...(unverifiedFrontier && unverifiedFrontier.length > 0 && { unverifiedFrontier }),
|
|
309
|
+
...(downAccount && { treeAccount: downAccount }),
|
|
310
|
+
...(account && { account }),
|
|
139
311
|
warnings: warnings.length > 0 ? warnings : undefined
|
|
140
312
|
};
|
|
141
313
|
} finally { index._endOp(); }
|
|
@@ -147,7 +319,7 @@ function trace(index, name, options = {}) {
|
|
|
147
319
|
*
|
|
148
320
|
* @param {object} index - ProjectIndex instance
|
|
149
321
|
* @param {string} name - Function name
|
|
150
|
-
* @param {object} options - { depth, file, className, all, exclude, includeMethods,
|
|
322
|
+
* @param {object} options - { depth, file, className, all, exclude, includeMethods, expandUnverified }
|
|
151
323
|
* @returns {object|null} Blast radius tree with summary
|
|
152
324
|
*/
|
|
153
325
|
function blast(index, name, options = {}) {
|
|
@@ -156,8 +328,8 @@ function blast(index, name, options = {}) {
|
|
|
156
328
|
const maxDepth = Math.max(0, options.depth ?? 3);
|
|
157
329
|
const maxChildren = options.all ? Infinity : 10;
|
|
158
330
|
const includeMethods = options.includeMethods ?? true;
|
|
159
|
-
const includeUncertain = options.includeUncertain || false;
|
|
160
331
|
const exclude = options.exclude || [];
|
|
332
|
+
const expandUnverified = !!options.expandUnverified;
|
|
161
333
|
|
|
162
334
|
const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
|
|
163
335
|
if (!def) return null;
|
|
@@ -165,10 +337,24 @@ function blast(index, name, options = {}) {
|
|
|
165
337
|
const visited = new Set();
|
|
166
338
|
const callerCache = new Map();
|
|
167
339
|
const affectedFunctions = new Set();
|
|
340
|
+
const possiblyAffectedSet = new Set();
|
|
168
341
|
const affectedFiles = new Set();
|
|
342
|
+
const frontier = [];
|
|
169
343
|
let maxDepthReached = 0;
|
|
344
|
+
let rootRaw = null;
|
|
345
|
+
let rootFiltered = 0;
|
|
346
|
+
const treeAccount = {
|
|
347
|
+
nodesExpanded: 0,
|
|
348
|
+
confirmedEdges: 0,
|
|
349
|
+
unverifiedEdges: 0,
|
|
350
|
+
unverifiedByReason: {},
|
|
351
|
+
excludedTotal: 0,
|
|
352
|
+
excludedByReason: {},
|
|
353
|
+
filteredEdges: 0,
|
|
354
|
+
depthLimitNodes: 0,
|
|
355
|
+
};
|
|
170
356
|
|
|
171
|
-
const buildCallerTree = (funcDef, currentDepth) => {
|
|
357
|
+
const buildCallerTree = (funcDef, currentDepth, chainUnverified) => {
|
|
172
358
|
const key = `${funcDef.file}:${funcDef.startLine}`;
|
|
173
359
|
if (currentDepth > maxDepth) return null;
|
|
174
360
|
if (visited.has(key)) {
|
|
@@ -185,8 +371,12 @@ function blast(index, name, options = {}) {
|
|
|
185
371
|
|
|
186
372
|
if (currentDepth > maxDepthReached) maxDepthReached = currentDepth;
|
|
187
373
|
if (currentDepth > 0) {
|
|
188
|
-
|
|
189
|
-
|
|
374
|
+
if (chainUnverified) {
|
|
375
|
+
possiblyAffectedSet.add(key);
|
|
376
|
+
} else {
|
|
377
|
+
affectedFunctions.add(key);
|
|
378
|
+
affectedFiles.add(funcDef.file);
|
|
379
|
+
}
|
|
190
380
|
}
|
|
191
381
|
|
|
192
382
|
const node = {
|
|
@@ -194,75 +384,51 @@ function blast(index, name, options = {}) {
|
|
|
194
384
|
file: funcDef.relativePath,
|
|
195
385
|
line: funcDef.startLine,
|
|
196
386
|
type: funcDef.type || 'function',
|
|
387
|
+
...(chainUnverified && { chainUnverified: true }),
|
|
197
388
|
children: []
|
|
198
389
|
};
|
|
199
390
|
|
|
200
391
|
if (currentDepth < maxDepth) {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
});
|
|
211
|
-
|
|
392
|
+
const { confirmed, unverified, raw } = _contractCallers(index, funcDef, { includeMethods, callerCache });
|
|
393
|
+
treeAccount.nodesExpanded++;
|
|
394
|
+
_aggregateExcluded(treeAccount, raw);
|
|
395
|
+
if (currentDepth === 0) rootRaw = raw;
|
|
396
|
+
|
|
397
|
+
// Confirmed-tier call sites form the trunk
|
|
398
|
+
let callers = confirmed;
|
|
399
|
+
if (exclude.length > 0) {
|
|
400
|
+
const before = callers.length;
|
|
401
|
+
callers = callers.filter(c => index.matchesFilters(c.relativePath, { exclude }));
|
|
402
|
+
treeAccount.filteredEdges += before - callers.length;
|
|
403
|
+
if (currentDepth === 0) rootFiltered += before - callers.length;
|
|
212
404
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (!uniqueCallers.has(callerKey)) {
|
|
224
|
-
uniqueCallers.set(callerKey, {
|
|
225
|
-
name: c.callerName,
|
|
226
|
-
file: c.callerFile,
|
|
227
|
-
relativePath: c.relativePath,
|
|
228
|
-
startLine: c.callerStartLine,
|
|
229
|
-
endLine: c.callerEndLine,
|
|
230
|
-
callSites: 1
|
|
231
|
-
});
|
|
232
|
-
} else {
|
|
233
|
-
uniqueCallers.get(callerKey).callSites++;
|
|
234
|
-
}
|
|
405
|
+
treeAccount.confirmedEdges += callers.length;
|
|
406
|
+
|
|
407
|
+
// Unverified-tier candidates: visible frontier entries,
|
|
408
|
+
// expanded only under expandUnverified.
|
|
409
|
+
let nodeUnverified = unverified;
|
|
410
|
+
if (exclude.length > 0) {
|
|
411
|
+
const before = nodeUnverified.length;
|
|
412
|
+
nodeUnverified = nodeUnverified.filter(c => index.matchesFilters(c.relativePath, { exclude }));
|
|
413
|
+
treeAccount.filteredEdges += before - nodeUnverified.length;
|
|
414
|
+
if (currentDepth === 0) rootFiltered += before - nodeUnverified.length;
|
|
235
415
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
callerDef = {
|
|
247
|
-
name: caller.name,
|
|
248
|
-
file: caller.file,
|
|
249
|
-
relativePath: caller.relativePath,
|
|
250
|
-
startLine: caller.startLine,
|
|
251
|
-
endLine: caller.endLine,
|
|
252
|
-
type: 'function'
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
callerEntries.push({ def: callerDef, callSites: caller.callSites });
|
|
416
|
+
for (const u of nodeUnverified) {
|
|
417
|
+
treeAccount.unverifiedEdges++;
|
|
418
|
+
const r = u.reason || 'unverified';
|
|
419
|
+
treeAccount.unverifiedByReason[r] = (treeAccount.unverifiedByReason[r] || 0) + 1;
|
|
420
|
+
frontier.push({
|
|
421
|
+
atNode: { name: node.name, file: node.file, line: node.line },
|
|
422
|
+
hop: currentDepth + 1,
|
|
423
|
+
...u,
|
|
424
|
+
...(expandUnverified && u.callerName ? { expanded: true } : {}),
|
|
425
|
+
});
|
|
257
426
|
}
|
|
258
427
|
|
|
259
|
-
|
|
260
|
-
callerEntries.sort((a, b) =>
|
|
261
|
-
a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
|
|
262
|
-
);
|
|
428
|
+
const callerEntries = _resolveCallerEntries(index, callers, []);
|
|
263
429
|
|
|
264
430
|
for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
|
|
265
|
-
const childTree = buildCallerTree(cDef, currentDepth + 1);
|
|
431
|
+
const childTree = buildCallerTree(cDef, currentDepth + 1, chainUnverified);
|
|
266
432
|
if (childTree) {
|
|
267
433
|
childTree.callSites = callSites;
|
|
268
434
|
node.children.push(childTree);
|
|
@@ -273,19 +439,66 @@ function blast(index, name, options = {}) {
|
|
|
273
439
|
node.truncatedChildren = callerEntries.length - maxChildren;
|
|
274
440
|
// Count truncated callers in summary
|
|
275
441
|
for (const { def: cDef } of callerEntries.slice(maxChildren)) {
|
|
276
|
-
const
|
|
277
|
-
if (!visited.has(
|
|
278
|
-
|
|
279
|
-
|
|
442
|
+
const tKey = `${cDef.file}:${cDef.startLine}`;
|
|
443
|
+
if (!visited.has(tKey)) {
|
|
444
|
+
if (chainUnverified) {
|
|
445
|
+
possiblyAffectedSet.add(tKey);
|
|
446
|
+
} else {
|
|
447
|
+
affectedFunctions.add(tKey);
|
|
448
|
+
affectedFiles.add(cDef.file);
|
|
449
|
+
}
|
|
280
450
|
}
|
|
281
451
|
}
|
|
282
452
|
}
|
|
453
|
+
|
|
454
|
+
// Follow unverified edges on request: every downstream node is
|
|
455
|
+
// marked chainUnverified — reach asserted by an unverified hop
|
|
456
|
+
// is possible impact, never confirmed impact.
|
|
457
|
+
if (expandUnverified && nodeUnverified.length > 0) {
|
|
458
|
+
const unvEntries = _resolveCallerEntries(index, nodeUnverified, []);
|
|
459
|
+
for (const { def: cDef, callSites, reason } of unvEntries.slice(0, maxChildren)) {
|
|
460
|
+
const childTree = buildCallerTree(cDef, currentDepth + 1, true);
|
|
461
|
+
if (childTree) {
|
|
462
|
+
childTree.callSites = callSites;
|
|
463
|
+
childTree.viaUnverified = reason || 'unverified';
|
|
464
|
+
node.children.push(childTree);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
for (const { def: cDef } of unvEntries.slice(maxChildren)) {
|
|
468
|
+
const tKey = `${cDef.file}:${cDef.startLine}`;
|
|
469
|
+
if (!visited.has(tKey)) possiblyAffectedSet.add(tKey);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
// Depth limit: this node's callers were not searched.
|
|
474
|
+
treeAccount.depthLimitNodes++;
|
|
283
475
|
}
|
|
284
476
|
|
|
285
477
|
return node;
|
|
286
478
|
};
|
|
287
479
|
|
|
288
|
-
const tree = buildCallerTree(def, 0);
|
|
480
|
+
const tree = buildCallerTree(def, 0, false);
|
|
481
|
+
|
|
482
|
+
// Root text-ground account (the context/impact contract at hop 1).
|
|
483
|
+
// Ground call-lines no candidate claimed are frontier entries too —
|
|
484
|
+
// counted in the account's unverified total, listed here.
|
|
485
|
+
let account;
|
|
486
|
+
if (rootRaw) {
|
|
487
|
+
const { composeAccount, callNotResolvedEntries } = require('./analysis');
|
|
488
|
+
account = composeAccount(index, name, rootRaw,
|
|
489
|
+
rootFiltered > 0 ? { total: rootFiltered, byFlag: { exclude: rootFiltered } } : undefined);
|
|
490
|
+
for (const e of callNotResolvedEntries(index, account, options)) {
|
|
491
|
+
treeAccount.unverifiedEdges++;
|
|
492
|
+
treeAccount.unverifiedByReason['call-not-resolved'] =
|
|
493
|
+
(treeAccount.unverifiedByReason['call-not-resolved'] || 0) + 1;
|
|
494
|
+
frontier.push({
|
|
495
|
+
atNode: { name: def.name, file: def.relativePath, line: def.startLine },
|
|
496
|
+
hop: 1,
|
|
497
|
+
...e,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
_sortFrontier(frontier);
|
|
289
502
|
|
|
290
503
|
// Smart hints
|
|
291
504
|
if (tree && tree.children.length === 0) {
|
|
@@ -304,11 +517,17 @@ function blast(index, name, options = {}) {
|
|
|
304
517
|
line: def.startLine,
|
|
305
518
|
maxDepth,
|
|
306
519
|
includeMethods,
|
|
520
|
+
expandUnverified: expandUnverified || undefined,
|
|
307
521
|
tree,
|
|
522
|
+
unverifiedFrontier: frontier,
|
|
523
|
+
treeAccount,
|
|
524
|
+
...(account && { account }),
|
|
308
525
|
summary: {
|
|
309
526
|
totalAffected: affectedFunctions.size,
|
|
310
527
|
totalFiles: affectedFiles.size,
|
|
311
|
-
maxDepthReached
|
|
528
|
+
maxDepthReached,
|
|
529
|
+
unverifiedEdges: treeAccount.unverifiedEdges,
|
|
530
|
+
...(expandUnverified && { possiblyAffected: possiblyAffectedSet.size }),
|
|
312
531
|
},
|
|
313
532
|
warnings: warnings.length > 0 ? warnings : undefined
|
|
314
533
|
};
|
|
@@ -320,9 +539,15 @@ function blast(index, name, options = {}) {
|
|
|
320
539
|
* Like blast but focused on "how does execution reach this function?"
|
|
321
540
|
* Marks leaf nodes (functions with no callers) as entry points.
|
|
322
541
|
*
|
|
542
|
+
* Entry-point soundness (tree contract): a node is an entry point only when
|
|
543
|
+
* it has zero confirmed AND zero unverified caller candidates. Zero confirmed
|
|
544
|
+
* with unverified candidates renders `unverifiedCallerCount` instead — the
|
|
545
|
+
* legacy behavior marked such nodes "entry point" after silently dropping
|
|
546
|
+
* possible-dispatch callers.
|
|
547
|
+
*
|
|
323
548
|
* @param {object} index - ProjectIndex instance
|
|
324
549
|
* @param {string} name - Function name
|
|
325
|
-
* @param {object} options - { depth, file, className, all, exclude, includeMethods,
|
|
550
|
+
* @param {object} options - { depth, file, className, all, exclude, includeMethods, expandUnverified }
|
|
326
551
|
* @returns {object|null} Reverse trace tree with entry points
|
|
327
552
|
*/
|
|
328
553
|
function reverseTrace(index, name, options = {}) {
|
|
@@ -331,8 +556,8 @@ function reverseTrace(index, name, options = {}) {
|
|
|
331
556
|
const maxDepth = Math.max(0, options.depth ?? 5);
|
|
332
557
|
const maxChildren = options.all ? Infinity : 10;
|
|
333
558
|
const includeMethods = options.includeMethods ?? true;
|
|
334
|
-
const includeUncertain = options.includeUncertain || false;
|
|
335
559
|
const exclude = options.exclude || [];
|
|
560
|
+
const expandUnverified = !!options.expandUnverified;
|
|
336
561
|
|
|
337
562
|
const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
|
|
338
563
|
if (!def) return null;
|
|
@@ -340,9 +565,37 @@ function reverseTrace(index, name, options = {}) {
|
|
|
340
565
|
const visited = new Set();
|
|
341
566
|
const callerCache = new Map();
|
|
342
567
|
const entryPoints = [];
|
|
568
|
+
const frontier = [];
|
|
343
569
|
let maxDepthReached = 0;
|
|
570
|
+
let rootRaw = null;
|
|
571
|
+
let rootFiltered = 0;
|
|
572
|
+
let rootUnverifiedCount = 0;
|
|
573
|
+
const treeAccount = {
|
|
574
|
+
nodesExpanded: 0,
|
|
575
|
+
confirmedEdges: 0,
|
|
576
|
+
unverifiedEdges: 0,
|
|
577
|
+
unverifiedByReason: {},
|
|
578
|
+
excludedTotal: 0,
|
|
579
|
+
excludedByReason: {},
|
|
580
|
+
filteredEdges: 0,
|
|
581
|
+
depthLimitNodes: 0,
|
|
582
|
+
};
|
|
344
583
|
|
|
345
|
-
|
|
584
|
+
// Tier-partitioned, exclude-filtered callers of a node (memoized).
|
|
585
|
+
const nodeCallers = (funcDef, isExpansion) => {
|
|
586
|
+
const { confirmed, unverified, raw } = _contractCallers(index, funcDef, { includeMethods, callerCache });
|
|
587
|
+
let conf = confirmed;
|
|
588
|
+
let unv = unverified;
|
|
589
|
+
if (exclude.length > 0) {
|
|
590
|
+
const before = conf.length + unv.length;
|
|
591
|
+
conf = conf.filter(c => index.matchesFilters(c.relativePath, { exclude }));
|
|
592
|
+
unv = unv.filter(c => index.matchesFilters(c.relativePath, { exclude }));
|
|
593
|
+
if (isExpansion) treeAccount.filteredEdges += before - conf.length - unv.length;
|
|
594
|
+
}
|
|
595
|
+
return { confirmed: conf, unverified: unv, raw };
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const buildCallerTree = (funcDef, currentDepth, chainUnverified) => {
|
|
346
599
|
const key = `${funcDef.file}:${funcDef.startLine}`;
|
|
347
600
|
if (currentDepth > maxDepth) return null;
|
|
348
601
|
if (visited.has(key)) {
|
|
@@ -363,69 +616,35 @@ function reverseTrace(index, name, options = {}) {
|
|
|
363
616
|
file: funcDef.relativePath,
|
|
364
617
|
line: funcDef.startLine,
|
|
365
618
|
type: funcDef.type || 'function',
|
|
619
|
+
...(chainUnverified && { chainUnverified: true }),
|
|
366
620
|
children: []
|
|
367
621
|
};
|
|
368
622
|
|
|
369
623
|
if (currentDepth < maxDepth) {
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
includeMethods,
|
|
377
|
-
includeUncertain,
|
|
378
|
-
targetDefinitions: funcDef.bindingId ? [funcDef] : undefined,
|
|
379
|
-
});
|
|
380
|
-
callerCache.set(callerCacheKey, callers);
|
|
624
|
+
const { confirmed, unverified, raw } = nodeCallers(funcDef, true);
|
|
625
|
+
treeAccount.nodesExpanded++;
|
|
626
|
+
_aggregateExcluded(treeAccount, raw);
|
|
627
|
+
if (currentDepth === 0) {
|
|
628
|
+
rootRaw = raw;
|
|
629
|
+
rootUnverifiedCount = unverified.length;
|
|
381
630
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
name: c.callerName,
|
|
394
|
-
file: c.callerFile,
|
|
395
|
-
relativePath: c.relativePath,
|
|
396
|
-
startLine: c.callerStartLine,
|
|
397
|
-
endLine: c.callerEndLine,
|
|
398
|
-
callSites: 1
|
|
399
|
-
});
|
|
400
|
-
} else {
|
|
401
|
-
uniqueCallers.get(callerKey).callSites++;
|
|
402
|
-
}
|
|
631
|
+
treeAccount.confirmedEdges += confirmed.length;
|
|
632
|
+
for (const u of unverified) {
|
|
633
|
+
treeAccount.unverifiedEdges++;
|
|
634
|
+
const r = u.reason || 'unverified';
|
|
635
|
+
treeAccount.unverifiedByReason[r] = (treeAccount.unverifiedByReason[r] || 0) + 1;
|
|
636
|
+
frontier.push({
|
|
637
|
+
atNode: { name: node.name, file: node.file, line: node.line },
|
|
638
|
+
hop: currentDepth + 1,
|
|
639
|
+
...u,
|
|
640
|
+
...(expandUnverified && u.callerName ? { expanded: true } : {}),
|
|
641
|
+
});
|
|
403
642
|
}
|
|
404
643
|
|
|
405
|
-
|
|
406
|
-
const callerEntries = [];
|
|
407
|
-
for (const [, caller] of uniqueCallers) {
|
|
408
|
-
const defs = index.symbols.get(caller.name);
|
|
409
|
-
let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
|
|
410
|
-
if (!callerDef) {
|
|
411
|
-
callerDef = {
|
|
412
|
-
name: caller.name,
|
|
413
|
-
file: caller.file,
|
|
414
|
-
relativePath: caller.relativePath,
|
|
415
|
-
startLine: caller.startLine,
|
|
416
|
-
endLine: caller.endLine,
|
|
417
|
-
type: 'function'
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
callerEntries.push({ def: callerDef, callSites: caller.callSites });
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
callerEntries.sort((a, b) =>
|
|
424
|
-
a.def.file.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
|
|
425
|
-
);
|
|
644
|
+
const callerEntries = _resolveCallerEntries(index, confirmed, []);
|
|
426
645
|
|
|
427
646
|
for (const { def: cDef, callSites } of callerEntries.slice(0, maxChildren)) {
|
|
428
|
-
const childTree = buildCallerTree(cDef, currentDepth + 1);
|
|
647
|
+
const childTree = buildCallerTree(cDef, currentDepth + 1, chainUnverified);
|
|
429
648
|
if (childTree) {
|
|
430
649
|
childTree.callSites = callSites;
|
|
431
650
|
node.children.push(childTree);
|
|
@@ -435,60 +654,87 @@ function reverseTrace(index, name, options = {}) {
|
|
|
435
654
|
if (callerEntries.length > maxChildren) {
|
|
436
655
|
node.truncatedChildren = callerEntries.length - maxChildren;
|
|
437
656
|
// Count entry points in truncated branches so summary is accurate
|
|
438
|
-
// Use callerCache to avoid redundant findCallers calls
|
|
439
657
|
for (const { def: cDef } of callerEntries.slice(maxChildren)) {
|
|
440
658
|
const cKey = `${cDef.file}:${cDef.startLine}`;
|
|
441
659
|
if (!visited.has(cKey)) {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
: `${cDef.name}:${cKey}`;
|
|
445
|
-
let cCallers = callerCache.get(cCacheKey);
|
|
446
|
-
if (!cCallers) {
|
|
447
|
-
cCallers = index.findCallers(cDef.name, {
|
|
448
|
-
includeMethods, includeUncertain,
|
|
449
|
-
targetDefinitions: cDef.bindingId ? [cDef] : undefined,
|
|
450
|
-
maxResults: 1, // Only need to know if any exist
|
|
451
|
-
});
|
|
452
|
-
callerCache.set(cCacheKey, cCallers);
|
|
453
|
-
}
|
|
454
|
-
if (cCallers.length === 0) {
|
|
660
|
+
const tiers = nodeCallers(cDef, false);
|
|
661
|
+
if (tiers.confirmed.length === 0 && tiers.unverified.length === 0) {
|
|
455
662
|
entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(index.root, cDef.file), line: cDef.startLine });
|
|
456
663
|
}
|
|
457
664
|
}
|
|
458
665
|
}
|
|
459
666
|
}
|
|
460
667
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
668
|
+
if (expandUnverified && unverified.length > 0) {
|
|
669
|
+
const unvEntries = _resolveCallerEntries(index, unverified, []);
|
|
670
|
+
for (const { def: cDef, callSites, reason } of unvEntries.slice(0, maxChildren)) {
|
|
671
|
+
const childTree = buildCallerTree(cDef, currentDepth + 1, true);
|
|
672
|
+
if (childTree) {
|
|
673
|
+
childTree.callSites = callSites;
|
|
674
|
+
childTree.viaUnverified = reason || 'unverified';
|
|
675
|
+
node.children.push(childTree);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Entry point only when BOTH tiers are empty; unverified-only
|
|
681
|
+
// nodes are visibly not-confirmed instead.
|
|
682
|
+
if (callerEntries.length === 0 && currentDepth > 0) {
|
|
683
|
+
if (unverified.length === 0) {
|
|
684
|
+
node.entryPoint = true;
|
|
685
|
+
entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
|
|
686
|
+
} else {
|
|
687
|
+
node.unverifiedCallerCount = unverified.length;
|
|
688
|
+
}
|
|
465
689
|
}
|
|
466
690
|
} else if (currentDepth > 0) {
|
|
467
691
|
// At depth limit: check if this node is an entry point
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
|
|
692
|
+
treeAccount.depthLimitNodes++;
|
|
693
|
+
const tiers = nodeCallers(funcDef, false);
|
|
694
|
+
if (tiers.confirmed.filter(c => c.callerName).length === 0) {
|
|
695
|
+
if (tiers.unverified.length === 0) {
|
|
696
|
+
node.entryPoint = true;
|
|
697
|
+
entryPoints.push({ name: funcDef.name, file: funcDef.relativePath, line: funcDef.startLine });
|
|
698
|
+
} else {
|
|
699
|
+
node.unverifiedCallerCount = tiers.unverified.length;
|
|
700
|
+
}
|
|
478
701
|
}
|
|
479
702
|
}
|
|
480
703
|
|
|
481
704
|
return node;
|
|
482
705
|
};
|
|
483
706
|
|
|
484
|
-
const tree = buildCallerTree(def, 0);
|
|
707
|
+
const tree = buildCallerTree(def, 0, false);
|
|
485
708
|
|
|
486
|
-
// Also mark root as entry point if it has no callers
|
|
709
|
+
// Also mark root as entry point if it has no callers in either tier
|
|
487
710
|
if (tree && tree.children.length === 0 && maxDepth > 0) {
|
|
488
|
-
|
|
489
|
-
|
|
711
|
+
if (rootUnverifiedCount === 0) {
|
|
712
|
+
tree.entryPoint = true;
|
|
713
|
+
entryPoints.push({ name: def.name, file: def.relativePath, line: def.startLine });
|
|
714
|
+
} else {
|
|
715
|
+
tree.unverifiedCallerCount = rootUnverifiedCount;
|
|
716
|
+
}
|
|
490
717
|
}
|
|
491
718
|
|
|
719
|
+
// Root text-ground account + unclaimed ground call-lines
|
|
720
|
+
let account;
|
|
721
|
+
if (rootRaw) {
|
|
722
|
+
const { composeAccount, callNotResolvedEntries } = require('./analysis');
|
|
723
|
+
account = composeAccount(index, name, rootRaw,
|
|
724
|
+
rootFiltered > 0 ? { total: rootFiltered, byFlag: { exclude: rootFiltered } } : undefined);
|
|
725
|
+
for (const e of callNotResolvedEntries(index, account, options)) {
|
|
726
|
+
treeAccount.unverifiedEdges++;
|
|
727
|
+
treeAccount.unverifiedByReason['call-not-resolved'] =
|
|
728
|
+
(treeAccount.unverifiedByReason['call-not-resolved'] || 0) + 1;
|
|
729
|
+
frontier.push({
|
|
730
|
+
atNode: { name: def.name, file: def.relativePath, line: def.startLine },
|
|
731
|
+
hop: 1,
|
|
732
|
+
...e,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
_sortFrontier(frontier);
|
|
737
|
+
|
|
492
738
|
// Smart hints
|
|
493
739
|
if (tree && tree.children.length === 0) {
|
|
494
740
|
if (maxDepth === 0) {
|
|
@@ -506,12 +752,17 @@ function reverseTrace(index, name, options = {}) {
|
|
|
506
752
|
line: def.startLine,
|
|
507
753
|
maxDepth,
|
|
508
754
|
includeMethods,
|
|
755
|
+
expandUnverified: expandUnverified || undefined,
|
|
509
756
|
tree,
|
|
510
757
|
entryPoints,
|
|
758
|
+
unverifiedFrontier: frontier,
|
|
759
|
+
treeAccount,
|
|
760
|
+
...(account && { account }),
|
|
511
761
|
summary: {
|
|
512
762
|
totalEntryPoints: entryPoints.length,
|
|
513
763
|
totalFunctions: visited.size - 1, // exclude root
|
|
514
|
-
maxDepthReached
|
|
764
|
+
maxDepthReached,
|
|
765
|
+
unverifiedEdges: treeAccount.unverifiedEdges,
|
|
515
766
|
},
|
|
516
767
|
warnings: warnings.length > 0 ? warnings : undefined
|
|
517
768
|
};
|
|
@@ -522,42 +773,99 @@ function reverseTrace(index, name, options = {}) {
|
|
|
522
773
|
* Find tests affected by a change to the given function.
|
|
523
774
|
* Composes blast() (transitive callers) with test file scanning.
|
|
524
775
|
*
|
|
776
|
+
* Two bands (tree contract): `affectedFunctions`/`testFiles` come from the
|
|
777
|
+
* confirmed-chain closure; names reachable only through >= 1 unverified hop
|
|
778
|
+
* land in `possiblyAffected`, their additional test files in
|
|
779
|
+
* `possiblyAffectedTests`. Coverage/uncovered claims are confirmed-band only.
|
|
780
|
+
*
|
|
525
781
|
* @param {object} index - ProjectIndex instance
|
|
526
782
|
* @param {string} name - Function name
|
|
527
|
-
* @param {object} options - { depth, file, className, exclude, includeMethods
|
|
783
|
+
* @param {object} options - { depth, file, className, exclude, includeMethods }
|
|
528
784
|
* @returns {object|null} Affected test files with coverage stats
|
|
529
785
|
*/
|
|
530
786
|
function affectedTests(index, name, options = {}) {
|
|
531
787
|
index._beginOp();
|
|
532
788
|
try {
|
|
533
|
-
|
|
789
|
+
const maxDepth = Math.max(0, options.depth ?? 3);
|
|
790
|
+
// Step 1: confirmed closure via blast (contract mode, no truncation)
|
|
534
791
|
const blastResult = index.blast(name, {
|
|
535
|
-
depth:
|
|
792
|
+
depth: maxDepth,
|
|
536
793
|
file: options.file,
|
|
537
794
|
className: options.className,
|
|
538
795
|
all: true,
|
|
539
796
|
exclude: options.exclude,
|
|
540
797
|
includeMethods: options.includeMethods,
|
|
541
|
-
includeUncertain: options.includeUncertain,
|
|
542
798
|
});
|
|
543
799
|
if (!blastResult) return null;
|
|
544
800
|
|
|
545
|
-
// Step 2: Collect
|
|
801
|
+
// Step 2: Collect confirmed-affected function names (and node keys)
|
|
546
802
|
const affectedNames = new Set();
|
|
803
|
+
const confirmedKeys = new Set();
|
|
547
804
|
affectedNames.add(name);
|
|
548
805
|
const collectNames = (node) => {
|
|
549
806
|
if (!node) return;
|
|
550
807
|
affectedNames.add(node.name);
|
|
808
|
+
confirmedKeys.add(`${node.file}:${node.line}`);
|
|
551
809
|
for (const child of node.children || []) collectNames(child);
|
|
552
810
|
};
|
|
553
811
|
collectNames(blastResult.tree);
|
|
554
812
|
|
|
555
|
-
// Step
|
|
556
|
-
//
|
|
813
|
+
// Step 2b: possible closure — BFS seeded by the frontier's enclosing
|
|
814
|
+
// functions, following both edge tiers, bounded by the same depth.
|
|
815
|
+
// Names reached only this way are possibly affected, never confirmed.
|
|
557
816
|
const exclude = options.exclude;
|
|
558
817
|
const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
|
|
818
|
+
const includeMethods = options.includeMethods ?? true;
|
|
819
|
+
const possiblyNames = new Set();
|
|
820
|
+
{
|
|
821
|
+
const callerCache = new Map();
|
|
822
|
+
const possibleVisited = new Set();
|
|
823
|
+
const queue = [];
|
|
824
|
+
const enqueueCaller = (c, depth) => {
|
|
825
|
+
if (!c.callerName || !c.callerFile) return;
|
|
826
|
+
if (excludeArr.length > 0 && !index.matchesFilters(c.relativePath, { exclude: excludeArr })) return;
|
|
827
|
+
const defs = index.symbols.get(c.callerName);
|
|
828
|
+
let cDef = defs?.find(d => d.file === c.callerFile && d.startLine === c.callerStartLine);
|
|
829
|
+
if (!cDef) {
|
|
830
|
+
cDef = {
|
|
831
|
+
name: c.callerName,
|
|
832
|
+
file: c.callerFile,
|
|
833
|
+
relativePath: c.relativePath,
|
|
834
|
+
startLine: c.callerStartLine,
|
|
835
|
+
endLine: c.callerEndLine,
|
|
836
|
+
type: 'function'
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
queue.push({ def: cDef, depth });
|
|
840
|
+
};
|
|
841
|
+
for (const fe of blastResult.unverifiedFrontier || []) {
|
|
842
|
+
enqueueCaller(fe, fe.hop);
|
|
843
|
+
}
|
|
844
|
+
while (queue.length > 0) {
|
|
845
|
+
const { def: d, depth } = queue.shift();
|
|
846
|
+
if (depth > maxDepth) continue;
|
|
847
|
+
const k = `${d.relativePath || path.relative(index.root, d.file)}:${d.startLine}`;
|
|
848
|
+
if (possibleVisited.has(k)) continue;
|
|
849
|
+
possibleVisited.add(k);
|
|
850
|
+
if (!confirmedKeys.has(k) && !affectedNames.has(d.name)) {
|
|
851
|
+
possiblyNames.add(d.name);
|
|
852
|
+
}
|
|
853
|
+
if (depth < maxDepth) {
|
|
854
|
+
const { confirmed, unverified } = _contractCallers(index, d, { includeMethods, callerCache });
|
|
855
|
+
for (const c of confirmed) enqueueCaller(c, depth + 1);
|
|
856
|
+
for (const c of unverified) enqueueCaller(c, depth + 1);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// A name in both bands is confirmed — the possible band only adds.
|
|
860
|
+
for (const n of affectedNames) possiblyNames.delete(n);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Step 3: Scan test files for all affected names using AST
|
|
864
|
+
// Only count call and test-case matches as real coverage — not imports or bare references.
|
|
559
865
|
const className = options.className || null;
|
|
560
866
|
const results = [];
|
|
867
|
+
const possibleResults = [];
|
|
868
|
+
const scanNames = [...affectedNames, ...possiblyNames];
|
|
561
869
|
for (const [filePath, fileEntry] of index.files) {
|
|
562
870
|
let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
|
|
563
871
|
// Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
|
|
@@ -573,7 +881,7 @@ function affectedTests(index, name, options = {}) {
|
|
|
573
881
|
const content = index._readFile(filePath);
|
|
574
882
|
const fileMatches = new Map();
|
|
575
883
|
|
|
576
|
-
for (const funcName of
|
|
884
|
+
for (const funcName of scanNames) {
|
|
577
885
|
// Fast pre-check
|
|
578
886
|
if (!content.includes(funcName)) continue;
|
|
579
887
|
|
|
@@ -647,23 +955,41 @@ function affectedTests(index, name, options = {}) {
|
|
|
647
955
|
|
|
648
956
|
// Only count functions with call or test-case matches as covered.
|
|
649
957
|
// Import-only or reference-only functions are not real coverage.
|
|
650
|
-
const
|
|
958
|
+
const realCoveredAll = coveredFunctions.filter(fn => {
|
|
651
959
|
const fnMatches = deduped.filter(m => m.functionName === fn);
|
|
652
960
|
return fnMatches.some(m => m.matchType === 'call' || m.matchType === 'test-case');
|
|
653
961
|
});
|
|
962
|
+
const realCoveredFunctions = realCoveredAll.filter(fn => affectedNames.has(fn));
|
|
963
|
+
const possiblyCovered = realCoveredAll.filter(fn => possiblyNames.has(fn));
|
|
654
964
|
|
|
655
|
-
// Only include file if it has real coverage
|
|
656
|
-
const realMatches = deduped.filter(m =>
|
|
657
|
-
m.matchType === 'call' || m.matchType === 'test-case' ||
|
|
658
|
-
realCoveredFunctions.includes(m.functionName)
|
|
659
|
-
);
|
|
660
965
|
if (realCoveredFunctions.length > 0) {
|
|
966
|
+
// Confirmed band: matches for confirmed-covered names
|
|
967
|
+
const realMatches = deduped.filter(m =>
|
|
968
|
+
affectedNames.has(m.functionName) &&
|
|
969
|
+
(m.matchType === 'call' || m.matchType === 'test-case' ||
|
|
970
|
+
realCoveredFunctions.includes(m.functionName))
|
|
971
|
+
);
|
|
661
972
|
results.push({
|
|
662
973
|
file: fileEntry.relativePath,
|
|
663
974
|
coveredFunctions: realCoveredFunctions,
|
|
975
|
+
...(possiblyCovered.length > 0 && { possiblyCovered }),
|
|
664
976
|
matchCount: realMatches.length,
|
|
665
977
|
matches: realMatches
|
|
666
978
|
});
|
|
979
|
+
} else if (possiblyCovered.length > 0) {
|
|
980
|
+
// Possible band: file reaches the change only through
|
|
981
|
+
// unverified chains.
|
|
982
|
+
const possibleMatches = deduped.filter(m =>
|
|
983
|
+
possiblyNames.has(m.functionName) &&
|
|
984
|
+
(m.matchType === 'call' || m.matchType === 'test-case' ||
|
|
985
|
+
possiblyCovered.includes(m.functionName))
|
|
986
|
+
);
|
|
987
|
+
possibleResults.push({
|
|
988
|
+
file: fileEntry.relativePath,
|
|
989
|
+
coveredFunctions: possiblyCovered,
|
|
990
|
+
matchCount: possibleMatches.length,
|
|
991
|
+
matches: possibleMatches
|
|
992
|
+
});
|
|
667
993
|
}
|
|
668
994
|
}
|
|
669
995
|
} catch (e) { /* skip unreadable */ }
|
|
@@ -671,22 +997,33 @@ function affectedTests(index, name, options = {}) {
|
|
|
671
997
|
|
|
672
998
|
// Sort by coverage breadth then alphabetically
|
|
673
999
|
results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
|
|
1000
|
+
possibleResults.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
|
|
674
1001
|
|
|
675
1002
|
// Compute coverage stats.
|
|
676
1003
|
// Filter out test function names from affectedNames — they are callers,
|
|
677
1004
|
// not production symbols that need test coverage.
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
//
|
|
681
|
-
|
|
1005
|
+
const isProductionName = (n) => {
|
|
1006
|
+
// Check if this name is only found in test files. Inline test
|
|
1007
|
+
// functions (#[test] fns in Rust's #[cfg(test)] mods, Go Test*)
|
|
1008
|
+
// live in production-path FILES but are tests themselves — the
|
|
1009
|
+
// language's getEntryPointKind says so; they need no coverage.
|
|
682
1010
|
for (const [fp, fe] of index.files) {
|
|
683
1011
|
if (isTestFile(fe.relativePath, fe.language)) continue;
|
|
684
|
-
|
|
1012
|
+
const langModule = getLanguageModule(fe.language);
|
|
1013
|
+
const kindOf = langModule?.getEntryPointKind;
|
|
1014
|
+
if (fe.symbols?.some(s => s.name === n && (!kindOf || kindOf(s) !== 'test'))) {
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
685
1017
|
}
|
|
686
|
-
|
|
1018
|
+
return false;
|
|
1019
|
+
};
|
|
1020
|
+
const productionNames = new Set();
|
|
1021
|
+
for (const n of affectedNames) {
|
|
1022
|
+
if (isProductionName(n)) productionNames.add(n);
|
|
687
1023
|
}
|
|
688
1024
|
// Fall back to full set if filtering removed everything (e.g., test-only project)
|
|
689
1025
|
const namesForCoverage = productionNames.size > 0 ? productionNames : affectedNames;
|
|
1026
|
+
const possiblyProduction = [...possiblyNames].filter(isProductionName);
|
|
690
1027
|
|
|
691
1028
|
const coveredSet = new Set();
|
|
692
1029
|
for (const r of results) for (const f of r.coveredFunctions) {
|
|
@@ -698,12 +1035,19 @@ function affectedTests(index, name, options = {}) {
|
|
|
698
1035
|
root: blastResult.root, file: blastResult.file, line: blastResult.line,
|
|
699
1036
|
depth: blastResult.maxDepth,
|
|
700
1037
|
affectedFunctions: [...namesForCoverage],
|
|
1038
|
+
possiblyAffected: possiblyProduction,
|
|
701
1039
|
testFiles: results,
|
|
1040
|
+
possiblyAffectedTests: possibleResults,
|
|
1041
|
+
...(blastResult.account && { account: blastResult.account }),
|
|
1042
|
+
treeAccount: blastResult.treeAccount,
|
|
702
1043
|
summary: {
|
|
703
1044
|
totalAffected: namesForCoverage.size,
|
|
704
1045
|
totalTestFiles: results.length,
|
|
705
1046
|
coveredFunctions: coveredSet.size,
|
|
706
1047
|
uncoveredCount: uncovered.length,
|
|
1048
|
+
possiblyAffected: possiblyProduction.length,
|
|
1049
|
+
possiblyAffectedTests: possibleResults.length,
|
|
1050
|
+
unverifiedEdges: blastResult.summary ? blastResult.summary.unverifiedEdges : 0,
|
|
707
1051
|
},
|
|
708
1052
|
uncovered,
|
|
709
1053
|
warnings: blastResult.warnings,
|