ucn 5.5.3 → 5.6.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 +37 -12
- package/.claude/skills/ucn/references/commands.md +18 -14
- package/.claude/skills/ucn/references/resolution-rules.md +4 -0
- package/.claude/skills/ucn/references/trust-contract.md +4 -4
- package/CHANGELOG.md +485 -0
- package/README.md +66 -29
- package/cli/flags.js +184 -0
- package/cli/index.js +137 -324
- package/core/account.js +44 -87
- package/core/analysis.js +274 -108
- package/core/ast-analysis.js +2 -0
- package/core/bridge.js +14 -0
- package/core/cache.js +103 -16
- package/core/call-fit.js +130 -0
- package/core/callers.js +353 -71
- package/core/capture.js +363 -0
- package/core/change-kinds.js +1107 -0
- package/core/check.js +1150 -139
- package/core/command-contracts.js +8 -7
- package/core/constructed-class.js +1195 -0
- package/core/contract-membership.js +142 -0
- package/core/deadcode.js +455 -106
- package/core/deletion.js +421 -0
- package/core/discovery.js +104 -9
- package/core/entrypoint-toml.js +110 -0
- package/core/entrypoints.js +12 -6
- package/core/evidence-bands.js +68 -0
- package/core/evidence-bands.json +44 -0
- package/core/execute.js +172 -161
- package/core/hot-summary.js +241 -0
- package/core/index-ir.js +5 -3
- package/core/ir.js +6 -2
- package/core/member-signatures.js +789 -0
- package/core/outgoing-check.js +116 -0
- package/core/output/analysis-ext.js +20 -10
- package/core/output/analysis.js +98 -60
- package/core/output/check.js +101 -37
- package/core/output/doctor.js +34 -1
- package/core/output/find.js +2 -1
- package/core/output/lines.js +63 -3
- package/core/output/public.js +29 -11
- package/core/output/refactoring.js +32 -0
- package/core/output/reporting.js +19 -2
- package/core/output/search.js +14 -2
- package/core/output/tracing.js +36 -8
- package/core/output-budget.js +146 -61
- package/core/parallel-build.js +3 -3
- package/core/project.js +106 -23
- package/core/provenance-type.js +12 -5
- package/core/public-command.js +35 -9
- package/core/python-binding-identity.js +707 -0
- package/core/python-modules.js +6 -0
- package/core/reflection.js +248 -31
- package/core/registry.js +623 -72
- package/core/reporting.js +69 -34
- package/core/review-items.js +75 -0
- package/core/runtime-entrypoints.js +159 -0
- package/core/search.js +148 -164
- package/core/shared.js +0 -45
- package/core/snapshot.js +350 -0
- package/core/source-universe.js +707 -0
- package/core/test-units.js +3698 -0
- package/core/tracing.js +270 -658
- package/core/verify.js +63 -22
- package/languages/adapter.js +8 -3
- package/languages/c-family.js +12 -0
- package/languages/csharp.js +634 -0
- package/languages/go.js +120 -0
- package/languages/index.js +466 -4
- package/languages/java.js +712 -0
- package/languages/javascript.js +1498 -6
- package/languages/lexical-scope.js +68 -0
- package/languages/python.js +1708 -3
- package/languages/rust.js +209 -104
- package/languages/static-js.js +88 -0
- package/mcp/server.js +44 -110
- package/package.json +11 -5
package/core/tracing.js
CHANGED
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
'use strict';
|
|
21
21
|
|
|
22
22
|
const path = require('path');
|
|
23
|
-
const {
|
|
23
|
+
const { codeUnitCompare, lineInRanges } = require('./shared');
|
|
24
|
+
const { paramDefault } = require('./registry');
|
|
24
25
|
const { isTestFile } = require('./discovery');
|
|
25
|
-
const { getCachedCalls } = require('./callers');
|
|
26
26
|
const { getLanguageAdapter } = require('../languages');
|
|
27
|
+
const { createTestModel, addTestCaseRows, linkFiles, commandsFor, testSite, testSiteKey } = require('./test-units');
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Contract-mode caller expansion for the tree commands. Memoizes the full
|
|
@@ -32,10 +33,10 @@ const { getLanguageAdapter } = require('../languages');
|
|
|
32
33
|
* frontier display and the affected-tests possible closure need them all.
|
|
33
34
|
*/
|
|
34
35
|
function _contractCallers(index, funcDef, { includeMethods, callerCache, pin }) {
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
if (funcDef.syntheticScope) return { confirmed: [], unverified: [], raw: [] };
|
|
37
|
+
const nodeKey = declarationKey(funcDef);
|
|
38
|
+
const definitionKey = `${funcDef.name}:${nodeKey}`;
|
|
39
|
+
const cacheKey = `${pin ? 'pinned:' : ''}${definitionKey}`;
|
|
39
40
|
let res = callerCache.get(cacheKey);
|
|
40
41
|
if (!res) {
|
|
41
42
|
const raw = index.findCallers(funcDef.name, {
|
|
@@ -83,56 +84,40 @@ function _aggregateExcluded(treeAccount, raw) {
|
|
|
83
84
|
* to its symbol-table definition (pseudo-definition when absent). Shared by
|
|
84
85
|
* the confirmed trunk and the expand-unverified path.
|
|
85
86
|
*/
|
|
87
|
+
function declarationKey(def) {
|
|
88
|
+
return [def.file, def.name, def.type || '', def.className || '', def.namespace || '',
|
|
89
|
+
def.sourceSpan?.start ?? def.startLine, def.sourceSpan?.end ?? def.endLine].join(':');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function callerDefinition(index, site) {
|
|
93
|
+
const candidates = (index.symbols.get(site.callerName) || []).filter(def =>
|
|
94
|
+
def.file === site.callerFile && def.startLine === site.callerStartLine);
|
|
95
|
+
const { containsCall } = require('./callable-scope');
|
|
96
|
+
const exact = candidates.filter(def => containsCall(def, testSite(site)) === true)
|
|
97
|
+
.sort((a, b) => (a.sourceSpan.end - a.sourceSpan.start) - (b.sourceSpan.end - b.sourceSpan.start));
|
|
98
|
+
if (exact.length > 0) return exact[0];
|
|
99
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
86
102
|
function _resolveCallerEntries(index, callers, exclude) {
|
|
87
103
|
const uniqueCallers = new Map();
|
|
88
|
-
for (const
|
|
89
|
-
if (exclude.length > 0 && !index.matchesFilters(
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
relativePath: c.relativePath,
|
|
101
|
-
startLine: callerStartLine,
|
|
102
|
-
endLine: hasNamedCaller ? c.callerEndLine : c.line,
|
|
103
|
-
callSites: 1,
|
|
104
|
-
reason: c.reason,
|
|
105
|
-
syntheticScope: !hasNamedCaller,
|
|
106
|
-
});
|
|
107
|
-
} else {
|
|
108
|
-
uniqueCallers.get(callerKey).callSites++;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const callerEntries = [];
|
|
113
|
-
for (const [, caller] of uniqueCallers) {
|
|
114
|
-
const defs = index.symbols.get(caller.name);
|
|
115
|
-
let callerDef = defs?.find(d => d.file === caller.file && d.startLine === caller.startLine);
|
|
116
|
-
if (!callerDef) {
|
|
117
|
-
// Pseudo-definition for callers not in symbol table
|
|
118
|
-
callerDef = {
|
|
119
|
-
name: caller.name,
|
|
120
|
-
file: caller.file,
|
|
121
|
-
relativePath: caller.relativePath,
|
|
122
|
-
startLine: caller.startLine,
|
|
123
|
-
endLine: caller.endLine,
|
|
124
|
-
type: 'function'
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
if (caller.syntheticScope) callerDef.syntheticScope = true;
|
|
128
|
-
callerEntries.push({ def: callerDef, callSites: caller.callSites, reason: caller.reason });
|
|
104
|
+
for (const site of callers) {
|
|
105
|
+
if (exclude.length > 0 && !index.matchesFilters(site.relativePath, { exclude })) continue;
|
|
106
|
+
const named = callerDefinition(index, site);
|
|
107
|
+
const def = named || {
|
|
108
|
+
name: site.callerName || '(module/anonymous scope)',
|
|
109
|
+
file: site.callerFile || site.file, relativePath: site.relativePath,
|
|
110
|
+
startLine: site.callerStartLine || site.line, endLine: site.callerEndLine || site.line,
|
|
111
|
+
type: 'function', syntheticScope: true,
|
|
112
|
+
};
|
|
113
|
+
const key = named ? declarationKey(named) : `${declarationKey(def)}:${testSiteKey(site)}`;
|
|
114
|
+
if (uniqueCallers.has(key)) uniqueCallers.get(key).callSites++;
|
|
115
|
+
else uniqueCallers.set(key, { def, callSites: 1, reason: site.reason });
|
|
129
116
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
codeUnitCompare(a.def
|
|
134
|
-
);
|
|
135
|
-
return callerEntries;
|
|
117
|
+
return [...uniqueCallers.values()].sort((a, b) =>
|
|
118
|
+
codeUnitCompare(a.def.file, b.def.file) || a.def.startLine - b.def.startLine ||
|
|
119
|
+
(a.def.sourceSpan?.start || 0) - (b.def.sourceSpan?.start || 0) ||
|
|
120
|
+
codeUnitCompare(declarationKey(a.def), declarationKey(b.def)));
|
|
136
121
|
}
|
|
137
122
|
|
|
138
123
|
/**
|
|
@@ -147,7 +132,7 @@ function trace(index, name, options = {}) {
|
|
|
147
132
|
index._beginOp();
|
|
148
133
|
try {
|
|
149
134
|
// Sanitize depth: use default for null/undefined, clamp negative to 0
|
|
150
|
-
const rawDepth = options.depth ??
|
|
135
|
+
const rawDepth = options.depth ?? paramDefault('depth', 'trace');
|
|
151
136
|
const maxDepth = Math.max(0, rawDepth);
|
|
152
137
|
const direction = options.direction || 'down'; // 'down' = callees, 'up' = callers, 'both'
|
|
153
138
|
const maxChildren = options.all ? Infinity : 10;
|
|
@@ -174,7 +159,7 @@ function trace(index, name, options = {}) {
|
|
|
174
159
|
|
|
175
160
|
const buildTree = (funcDef, currentDepth, dir) => {
|
|
176
161
|
const funcName = funcDef.name;
|
|
177
|
-
const key =
|
|
162
|
+
const key = declarationKey(funcDef);
|
|
178
163
|
if (currentDepth > maxDepth) {
|
|
179
164
|
return null;
|
|
180
165
|
}
|
|
@@ -340,7 +325,7 @@ function trace(index, name, options = {}) {
|
|
|
340
325
|
function blast(index, name, options = {}) {
|
|
341
326
|
index._beginOp();
|
|
342
327
|
try {
|
|
343
|
-
const maxDepth = Math.max(0, options.depth ??
|
|
328
|
+
const maxDepth = Math.max(0, options.depth ?? paramDefault('depth', 'trace'));
|
|
344
329
|
const maxChildren = options.all ? Infinity : 10;
|
|
345
330
|
const includeMethods = options.includeMethods ?? true;
|
|
346
331
|
const exclude = options.exclude || [];
|
|
@@ -354,7 +339,9 @@ function blast(index, name, options = {}) {
|
|
|
354
339
|
if (!def) return null;
|
|
355
340
|
|
|
356
341
|
const visited = new Set();
|
|
357
|
-
|
|
342
|
+
// A caller's answer about one definition is the same whichever
|
|
343
|
+
// root the walk started from: a selection over many roots shares it.
|
|
344
|
+
const callerCache = options._callerCache || new Map();
|
|
358
345
|
const affectedFunctions = new Set();
|
|
359
346
|
const possiblyAffectedSet = new Set();
|
|
360
347
|
const affectedFiles = new Set();
|
|
@@ -377,7 +364,7 @@ function blast(index, name, options = {}) {
|
|
|
377
364
|
};
|
|
378
365
|
|
|
379
366
|
const buildCallerTree = (funcDef, currentDepth, chainUnverified) => {
|
|
380
|
-
const key =
|
|
367
|
+
const key = declarationKey(funcDef);
|
|
381
368
|
if (currentDepth > maxDepth) return null;
|
|
382
369
|
if (visited.has(key)) {
|
|
383
370
|
return {
|
|
@@ -409,9 +396,12 @@ function blast(index, name, options = {}) {
|
|
|
409
396
|
...(chainUnverified && { chainUnverified: true }),
|
|
410
397
|
children: []
|
|
411
398
|
};
|
|
399
|
+
if (collect && !chainUnverified) collect.onNode?.(funcDef);
|
|
412
400
|
|
|
413
401
|
if (currentDepth < maxDepth) {
|
|
414
|
-
const { confirmed, unverified, raw } = _contractCallers(index, funcDef, {
|
|
402
|
+
const { confirmed, unverified, raw } = _contractCallers(index, funcDef, {
|
|
403
|
+
includeMethods, callerCache, pin: options._pinDefinitions,
|
|
404
|
+
});
|
|
415
405
|
treeAccount.nodesExpanded++;
|
|
416
406
|
_aggregateExcluded(treeAccount, raw);
|
|
417
407
|
if (currentDepth === 0) rootRaw = raw;
|
|
@@ -426,8 +416,7 @@ function blast(index, name, options = {}) {
|
|
|
426
416
|
if (currentDepth === 0) rootFiltered += before - callers.length;
|
|
427
417
|
}
|
|
428
418
|
const recursiveCallers = callers.filter(c =>
|
|
429
|
-
c
|
|
430
|
-
c.callerStartLine === funcDef.startLine);
|
|
419
|
+
declarationKey(callerDefinition(index, c) || {}) === declarationKey(funcDef));
|
|
431
420
|
if (recursiveCallers.length > 0) {
|
|
432
421
|
callers = callers.filter(c => !recursiveCallers.includes(c));
|
|
433
422
|
node.selfRecursive = true;
|
|
@@ -475,7 +464,7 @@ function blast(index, name, options = {}) {
|
|
|
475
464
|
treeAccount.truncatedChildren += node.truncatedChildren;
|
|
476
465
|
// Count truncated callers in summary
|
|
477
466
|
for (const { def: cDef } of callerEntries.slice(maxChildren)) {
|
|
478
|
-
const tKey =
|
|
467
|
+
const tKey = declarationKey(cDef);
|
|
479
468
|
if (!visited.has(tKey)) {
|
|
480
469
|
if (chainUnverified) {
|
|
481
470
|
possiblyAffectedSet.add(tKey);
|
|
@@ -501,7 +490,7 @@ function blast(index, name, options = {}) {
|
|
|
501
490
|
}
|
|
502
491
|
}
|
|
503
492
|
for (const { def: cDef } of unvEntries.slice(maxChildren)) {
|
|
504
|
-
const tKey =
|
|
493
|
+
const tKey = declarationKey(cDef);
|
|
505
494
|
if (!visited.has(tKey)) possiblyAffectedSet.add(tKey);
|
|
506
495
|
}
|
|
507
496
|
}
|
|
@@ -515,6 +504,14 @@ function blast(index, name, options = {}) {
|
|
|
515
504
|
|
|
516
505
|
const tree = buildCallerTree(def, 0, false);
|
|
517
506
|
|
|
507
|
+
if (options._testSitesOnly) {
|
|
508
|
+
// Internal check selection needs only the declarations observed
|
|
509
|
+
// by _collect. Caller tiering above still uses collectAccount;
|
|
510
|
+
// the root account/frontier below are public presentation, not
|
|
511
|
+
// evidence that can change the confirmed traversal.
|
|
512
|
+
return { root: name, file: def.relativePath, line: def.startLine, maxDepth };
|
|
513
|
+
}
|
|
514
|
+
|
|
518
515
|
// Root text-ground account (the context/impact contract at hop 1).
|
|
519
516
|
// Ground call-lines no candidate claimed are frontier entries too —
|
|
520
517
|
// counted in the account's unverified total, listed here.
|
|
@@ -590,7 +587,7 @@ function blast(index, name, options = {}) {
|
|
|
590
587
|
function reverseTrace(index, name, options = {}) {
|
|
591
588
|
index._beginOp();
|
|
592
589
|
try {
|
|
593
|
-
const maxDepth = Math.max(0, options.depth ??
|
|
590
|
+
const maxDepth = Math.max(0, options.depth ?? paramDefault('depth', 'trace', 'entrypoints'));
|
|
594
591
|
const maxChildren = options.all ? Infinity : 10;
|
|
595
592
|
const includeMethods = options.includeMethods ?? true;
|
|
596
593
|
const exclude = options.exclude || [];
|
|
@@ -631,8 +628,7 @@ function reverseTrace(index, name, options = {}) {
|
|
|
631
628
|
if (isExpansion) treeAccount.filteredEdges += before - conf.length - unv.length;
|
|
632
629
|
}
|
|
633
630
|
const recursive = conf.filter(c =>
|
|
634
|
-
c
|
|
635
|
-
c.callerStartLine === funcDef.startLine);
|
|
631
|
+
declarationKey(callerDefinition(index, c) || {}) === declarationKey(funcDef));
|
|
636
632
|
if (recursive.length > 0) {
|
|
637
633
|
conf = conf.filter(c => !recursive.includes(c));
|
|
638
634
|
if (isExpansion) treeAccount.recursiveEdges += recursive.length;
|
|
@@ -641,7 +637,7 @@ function reverseTrace(index, name, options = {}) {
|
|
|
641
637
|
};
|
|
642
638
|
|
|
643
639
|
const buildCallerTree = (funcDef, currentDepth, chainUnverified) => {
|
|
644
|
-
const key =
|
|
640
|
+
const key = declarationKey(funcDef);
|
|
645
641
|
if (currentDepth > maxDepth) return null;
|
|
646
642
|
if (visited.has(key)) {
|
|
647
643
|
return {
|
|
@@ -701,7 +697,7 @@ function reverseTrace(index, name, options = {}) {
|
|
|
701
697
|
node.truncatedChildren = callerEntries.length - maxChildren;
|
|
702
698
|
// Count entry points in truncated branches so summary is accurate
|
|
703
699
|
for (const { def: cDef } of callerEntries.slice(maxChildren)) {
|
|
704
|
-
const cKey =
|
|
700
|
+
const cKey = declarationKey(cDef);
|
|
705
701
|
if (!visited.has(cKey)) {
|
|
706
702
|
const tiers = nodeCallers(cDef, false);
|
|
707
703
|
if (tiers.confirmed.length === 0 && tiers.unverified.length === 0 &&
|
|
@@ -842,446 +838,208 @@ function affectedTests(index, name, options = {}) {
|
|
|
842
838
|
index._beginOp();
|
|
843
839
|
try {
|
|
844
840
|
const maxDepth = Math.max(0, options.depth ?? 3);
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const expandedNames = new Set();
|
|
853
|
-
const confirmedSitesByName = new Map(); // name → Map(relPath → Set(line))
|
|
841
|
+
// Traverse production callers only to the requested depth. Every
|
|
842
|
+
// declaration reached, including a boundary declaration, then has
|
|
843
|
+
// its immediate test sites adjudicated against its own identity.
|
|
844
|
+
// Inspecting those sites never extends the production closure.
|
|
845
|
+
const callerCache = options._callerCache || new Map();
|
|
846
|
+
const definitions = new Map();
|
|
847
|
+
const keyOf = declarationKey;
|
|
854
848
|
const blastResult = index.blast(name, {
|
|
855
|
-
depth: maxDepth,
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
includeMethods: options.includeMethods,
|
|
861
|
-
_collect: {
|
|
862
|
-
onExpand: (funcDef) => expandedNames.add(funcDef.name),
|
|
863
|
-
onConfirmed: (funcDef, c) => {
|
|
864
|
-
let byFile = confirmedSitesByName.get(funcDef.name);
|
|
865
|
-
if (!byFile) { byFile = new Map(); confirmedSitesByName.set(funcDef.name, byFile); }
|
|
866
|
-
let lines = byFile.get(c.relativePath);
|
|
867
|
-
if (!lines) { lines = new Set(); byFile.set(c.relativePath, lines); }
|
|
868
|
-
lines.add(c.line);
|
|
869
|
-
},
|
|
870
|
-
},
|
|
849
|
+
depth: maxDepth, file: options.file, className: options.className,
|
|
850
|
+
line: options.line, _callerCache: callerCache, _pinDefinitions: true,
|
|
851
|
+
all: true, exclude: options.exclude, includeMethods: options.includeMethods,
|
|
852
|
+
_collect: { onNode: def => definitions.set(keyOf(def), def) },
|
|
853
|
+
_testSitesOnly: options._testSitesOnly,
|
|
871
854
|
});
|
|
872
855
|
if (!blastResult) return null;
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
// Also record each name's CLASS identity from the tree — interior
|
|
876
|
-
// names were scanned bare (post-#239), so a test of a DIFFERENT
|
|
877
|
-
// class's same-named method got credited as coverage (fix #244:
|
|
878
|
-
// `asyncio.run(...)` credited as covering Mgr.run).
|
|
879
|
-
const affectedNames = new Set();
|
|
880
|
-
const confirmedKeys = new Set();
|
|
881
|
-
const nameToClasses = new Map(); // name → Set(className|null)
|
|
882
|
-
affectedNames.add(name);
|
|
883
|
-
const collectNames = (node) => {
|
|
884
|
-
if (!node) return;
|
|
885
|
-
affectedNames.add(node.name);
|
|
886
|
-
confirmedKeys.add(`${node.file}:${node.line}`);
|
|
887
|
-
const defs = index.symbols.get(node.name);
|
|
888
|
-
const d = defs?.find(x => x.relativePath === node.file && x.startLine === node.line);
|
|
889
|
-
let set = nameToClasses.get(node.name);
|
|
890
|
-
if (!set) { set = new Set(); nameToClasses.set(node.name, set); }
|
|
891
|
-
set.add(d?.className || null);
|
|
892
|
-
for (const child of node.children || []) collectNames(child);
|
|
893
|
-
};
|
|
894
|
-
collectNames(blastResult.tree);
|
|
895
|
-
|
|
896
|
-
// Step 2b: possible closure — BFS seeded by the frontier's enclosing
|
|
897
|
-
// functions, following both edge tiers, bounded by the same depth.
|
|
898
|
-
// Names reached only this way are possibly affected, never confirmed.
|
|
856
|
+
const affectedNames = new Set([...definitions.values()].map(def => def.name));
|
|
857
|
+
const possiblyNames = new Set();
|
|
899
858
|
const exclude = options.exclude;
|
|
900
859
|
const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
|
|
901
860
|
const includeMethods = options.includeMethods ?? true;
|
|
902
|
-
const
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
let cDef = defs?.find(d => d.file === c.callerFile && d.startLine === c.callerStartLine);
|
|
916
|
-
if (!cDef) {
|
|
917
|
-
cDef = {
|
|
918
|
-
name: c.callerName,
|
|
919
|
-
file: c.callerFile,
|
|
920
|
-
relativePath: c.relativePath,
|
|
921
|
-
startLine: c.callerStartLine,
|
|
922
|
-
endLine: c.callerEndLine,
|
|
923
|
-
type: 'function'
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
queue.push({ def: cDef, depth });
|
|
927
|
-
};
|
|
928
|
-
for (const fe of blastResult.unverifiedFrontier || []) {
|
|
929
|
-
enqueueCaller(fe, fe.hop);
|
|
930
|
-
// A frontier caller that is ITSELF a test function landed in
|
|
931
|
-
// neither band (fix #244, the unittest setUp idiom):
|
|
932
|
-
// isProductionName filters it from possiblyAffected, and the
|
|
933
|
-
// test scan greps for calls OF it — tests are never called.
|
|
934
|
-
// Route its FILE into possiblyAffectedTests directly.
|
|
935
|
-
// Anonymous test callbacks (`it('...', () => { x.save() })`)
|
|
936
|
-
// have no enclosing NAMED caller at all — the site's file is
|
|
937
|
-
// still test evidence, so route by the site file whenever it
|
|
938
|
-
// is a test file (fix #246; the caller-file and site file are
|
|
939
|
-
// the same file by construction when both exist).
|
|
940
|
-
const siteFile = fe.callerFile || fe.file;
|
|
941
|
-
if (siteFile) {
|
|
942
|
-
const cfe = index.files.get(siteFile);
|
|
943
|
-
if (cfe) {
|
|
944
|
-
let isTestCaller = isTestFile(cfe.relativePath, cfe.language);
|
|
945
|
-
if (!isTestCaller && fe.callerName) {
|
|
946
|
-
const defs = index.symbols.get(fe.callerName);
|
|
947
|
-
const d = defs?.find(x => x.file === fe.callerFile && x.startLine === fe.callerStartLine);
|
|
948
|
-
const kindOf = getLanguageAdapter(cfe.language)?.getEntryPointKind;
|
|
949
|
-
if (d && kindOf && kindOf(d) === 'test') isTestCaller = true;
|
|
950
|
-
}
|
|
951
|
-
if (!isTestCaller && fe.line != null) {
|
|
952
|
-
// Inline #[cfg(test)] regions of production files
|
|
953
|
-
// are test code too — but only within the ranges.
|
|
954
|
-
const ranges = inlineTestRanges(cfe);
|
|
955
|
-
if (ranges.length > 0 && lineInRanges(fe.line, ranges)) isTestCaller = true;
|
|
956
|
-
}
|
|
957
|
-
if (isTestCaller &&
|
|
958
|
-
(excludeArr.length === 0 || index.matchesFilters(cfe.relativePath, { exclude: excludeArr }))) {
|
|
959
|
-
const affName = fe.atNode?.name || name;
|
|
960
|
-
let entry = frontierTestHits.get(siteFile);
|
|
961
|
-
if (!entry) {
|
|
962
|
-
entry = { rel: cfe.relativePath, byName: new Map() };
|
|
963
|
-
frontierTestHits.set(siteFile, entry);
|
|
964
|
-
}
|
|
965
|
-
let hitLines = entry.byName.get(affName);
|
|
966
|
-
if (!hitLines) { hitLines = new Set(); entry.byName.set(affName, hitLines); }
|
|
967
|
-
if (fe.line != null) hitLines.add(fe.line);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
}
|
|
861
|
+
const model = options._testModel || createTestModel(index);
|
|
862
|
+
// The same multi-symbol operation reuses path classification. No
|
|
863
|
+
// source/configuration fact survives the outermost operation.
|
|
864
|
+
let testPaths = index._opMemos.get('affected-test-paths');
|
|
865
|
+
if (!testPaths) {
|
|
866
|
+
testPaths = new Map();
|
|
867
|
+
index._opMemos.set('affected-test-paths', testPaths);
|
|
868
|
+
}
|
|
869
|
+
const testInfo = filePath => {
|
|
870
|
+
const entry = index.files.get(filePath);
|
|
871
|
+
if (!entry) return null;
|
|
872
|
+
if (!testPaths.has(filePath)) {
|
|
873
|
+
testPaths.set(filePath, isTestFile(entry.relativePath, entry.language));
|
|
971
874
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
875
|
+
if (testPaths.get(filePath)) return { entry, ranges: null };
|
|
876
|
+
const ranges = model.testRangesOf(filePath);
|
|
877
|
+
return ranges.length ? { entry, ranges } : null;
|
|
878
|
+
};
|
|
879
|
+
const absoluteOf = new Map();
|
|
880
|
+
const confirmedRows = new Map();
|
|
881
|
+
const possibleRows = new Map();
|
|
882
|
+
const addSite = (site, functionName, confirmed) => {
|
|
883
|
+
const filePath = site.file || site.callerFile;
|
|
884
|
+
const info = testInfo(filePath);
|
|
885
|
+
if (!info || (info.ranges && !lineInRanges(site.line, info.ranges)) ||
|
|
886
|
+
(excludeArr.length && !index.matchesFilters(info.entry.relativePath, { exclude: excludeArr }))) return;
|
|
887
|
+
absoluteOf.set(info.entry.relativePath, filePath);
|
|
888
|
+
const rows = confirmed ? confirmedRows : possibleRows;
|
|
889
|
+
let row = rows.get(info.entry.relativePath);
|
|
890
|
+
if (!row) {
|
|
891
|
+
row = { file: info.entry.relativePath, linkedFunctions: [], matches: [] };
|
|
892
|
+
rows.set(row.file, row);
|
|
893
|
+
}
|
|
894
|
+
if (!row.linkedFunctions.includes(functionName)) row.linkedFunctions.push(functionName);
|
|
895
|
+
if (!row.matches.some(match => testSiteKey(match) === testSiteKey(site) && match.functionName === functionName)) {
|
|
896
|
+
row.matches.push({
|
|
897
|
+
...testSite(site),
|
|
898
|
+
content: (site.content || index.getLineContent(filePath, site.line) || '').trim(),
|
|
899
|
+
matchType: 'call', functionName,
|
|
900
|
+
...(!confirmed && { evidenceTier: 'unverified', reason: site.reason || 'unverified-chain' }),
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
const finishRows = rows => [...rows.values()].map(row => {
|
|
905
|
+
const filePath = absoluteOf.get(row.file);
|
|
906
|
+
const matches = [];
|
|
907
|
+
for (const functionName of row.linkedFunctions.sort(codeUnitCompare)) {
|
|
908
|
+
const own = row.matches.filter(match => match.functionName === functionName);
|
|
909
|
+
// Imports remain useful context once an actual test site
|
|
910
|
+
// links this file. They never create a link or select units.
|
|
911
|
+
const ranges = testInfo(filePath)?.ranges;
|
|
912
|
+
for (const usage of index._getCachedUsages(filePath, functionName) || []) {
|
|
913
|
+
if (usage.usageType !== 'import' ||
|
|
914
|
+
(ranges && !lineInRanges(usage.line, ranges)) ||
|
|
915
|
+
own.some(match => match.line === usage.line)) continue;
|
|
916
|
+
own.push({ line: usage.line,
|
|
917
|
+
content: (index.getLineContent(filePath, usage.line) || '').trim(),
|
|
918
|
+
matchType: 'import', functionName });
|
|
985
919
|
}
|
|
920
|
+
addTestCaseRows(model, index, filePath, functionName, own, {
|
|
921
|
+
counts: match => match.matchType === 'call', titleNeedsMatch: true,
|
|
922
|
+
extra: { functionName },
|
|
923
|
+
});
|
|
924
|
+
matches.push(...own);
|
|
986
925
|
}
|
|
987
|
-
|
|
988
|
-
|
|
926
|
+
row.matches = matches.sort((a, b) => a.line - b.line ||
|
|
927
|
+
codeUnitCompare(a.functionName, b.functionName) || codeUnitCompare(a.matchType, b.matchType));
|
|
928
|
+
row.matchCount = row.matches.length;
|
|
929
|
+
return row;
|
|
930
|
+
});
|
|
931
|
+
const queue = [];
|
|
932
|
+
const enqueue = (site, depth) => {
|
|
933
|
+
if (!site.callerName || !site.callerFile || depth > maxDepth) return;
|
|
934
|
+
if (excludeArr.length && !index.matchesFilters(site.relativePath, { exclude: excludeArr })) return;
|
|
935
|
+
const def = callerDefinition(index, site);
|
|
936
|
+
// Anonymous/module sites are test evidence but not declarations
|
|
937
|
+
// whose spelling can seed another production traversal.
|
|
938
|
+
if (def) queue.push({ def, depth });
|
|
939
|
+
};
|
|
940
|
+
for (const def of definitions.values()) {
|
|
941
|
+
const { confirmed, unverified } = _contractCallers(index, def, { includeMethods, callerCache, pin: true });
|
|
942
|
+
for (const site of confirmed) addSite(site, def.name, true);
|
|
943
|
+
if (!options._testSitesOnly) {
|
|
944
|
+
for (const site of unverified) addSite(site, def.name, false);
|
|
945
|
+
}
|
|
946
|
+
// Possible descendants are seeded by blast's bounded frontier
|
|
947
|
+
// below, never by the extra boundary site inspection.
|
|
989
948
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
for (const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
949
|
+
if (options._testSitesOnly) {
|
|
950
|
+
// check unions the confirmed physical sites of every changed
|
|
951
|
+
// declaration, then links their test units once. Preserve the
|
|
952
|
+
// synthetic case-header sites used by that final link, but do
|
|
953
|
+
// not traverse the unused possible band or project per-root
|
|
954
|
+
// units, inherited rows and commands that check discards.
|
|
955
|
+
return { testFiles: finishRows(confirmedRows) };
|
|
956
|
+
}
|
|
957
|
+
for (const site of blastResult.unverifiedFrontier || []) {
|
|
958
|
+
addSite(site, site.atNode?.name || name, false);
|
|
959
|
+
enqueue(site, site.hop);
|
|
960
|
+
}
|
|
961
|
+
const visited = new Set();
|
|
962
|
+
for (let next = 0; next < queue.length; next++) {
|
|
963
|
+
const { def, depth } = queue[next];
|
|
964
|
+
const key = keyOf(def);
|
|
965
|
+
if (visited.has(key) || definitions.has(key)) continue;
|
|
966
|
+
visited.add(key);
|
|
967
|
+
possiblyNames.add(def.name);
|
|
968
|
+
const { confirmed, unverified } = _contractCallers(index, def, { includeMethods, callerCache, pin: true });
|
|
969
|
+
for (const site of [...confirmed, ...unverified]) {
|
|
970
|
+
addSite(site, def.name, false);
|
|
971
|
+
if (depth < maxDepth) enqueue(site, depth + 1);
|
|
1011
972
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
// to re-derive (fix #246).
|
|
1022
|
-
const engineFileSites = confirmedSitesByName.get(funcName)?.get(fileEntry.relativePath) || null;
|
|
1023
|
-
// Whether the engine searched this name's callers at all
|
|
1024
|
-
// (depth-limit nodes were never adjudicated — the text
|
|
1025
|
-
// scan remains their best-effort fallback).
|
|
1026
|
-
const engineAdjudicated = expandedNames.has(funcName);
|
|
1027
|
-
|
|
1028
|
-
// Fast pre-check
|
|
1029
|
-
if (!content.includes(funcName) && !engineFileSites) continue;
|
|
1030
|
-
|
|
1031
|
-
// AST-based usage detection
|
|
1032
|
-
const astUsages = index._getCachedUsages(filePath, funcName) || [];
|
|
1033
|
-
if (astUsages.length === 0 && !engineFileSites) continue;
|
|
1034
|
-
|
|
1035
|
-
// className scoping: the user's pin applies to the ROOT
|
|
1036
|
-
// symbol's own name only (fix #239, G3-go-measured) — a
|
|
1037
|
-
// bare call to a standalone wrapper (SaveAll) can never
|
|
1038
|
-
// carry the root's class receiver. INTERIOR names with a
|
|
1039
|
-
// single unambiguous class identity in the blast tree
|
|
1040
|
-
// scope to it (fix #244): a test of a different class's
|
|
1041
|
-
// same-named method is not coverage of THIS closure.
|
|
1042
|
-
// Standalone/mixed-identity names keep the bare scan.
|
|
1043
|
-
let scopeToClass = null;
|
|
1044
|
-
if (funcName === name) {
|
|
1045
|
-
scopeToClass = className || null;
|
|
1046
|
-
} else {
|
|
1047
|
-
const classes = nameToClasses.get(funcName);
|
|
1048
|
-
if (classes && classes.size === 1) {
|
|
1049
|
-
const only = classes.values().next().value;
|
|
1050
|
-
if (only) scopeToClass = only;
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// A test file that DEFINES funcName itself owns bare-name
|
|
1055
|
-
// calls of it (#215/#222(3) scope physics — fix #244: a
|
|
1056
|
-
// private same-name helper let the coverage band claim
|
|
1057
|
-
// 100% while the same payload's account excluded the
|
|
1058
|
-
// site as other-definition). Exception: when the local
|
|
1059
|
-
// def IS part of the blast closure (Rust inline-test
|
|
1060
|
-
// files define the symbol AND test it), bare calls bind
|
|
1061
|
-
// the affected symbol — genuine coverage.
|
|
1062
|
-
const localDefs = (fileEntry.symbols || []).filter(s => s.name === funcName);
|
|
1063
|
-
const localShadow = localDefs.length > 0 &&
|
|
1064
|
-
localDefs.every(s => !confirmedKeys.has(`${fileEntry.relativePath}:${s.startLine}`)) &&
|
|
1065
|
-
!(fileEntry.importBindings || []).some(b => b.name === funcName);
|
|
1066
|
-
|
|
1067
|
-
// Build instance type map for className scoping (if applicable).
|
|
1068
|
-
// Scoping accepts the class plus its non-overriding
|
|
1069
|
-
// descendants — instances of a subclass that doesn't
|
|
1070
|
-
// override funcName dispatch to the target (fix #246).
|
|
1071
|
-
let instanceTypeMap = null;
|
|
1072
|
-
let dispatchNames = null;
|
|
1073
|
-
if (scopeToClass) {
|
|
1074
|
-
const dnKey = `${scopeToClass}:${funcName}`;
|
|
1075
|
-
dispatchNames = dispatchNamesCache.get(dnKey);
|
|
1076
|
-
if (!dispatchNames) {
|
|
1077
|
-
dispatchNames = classDispatchNames(index, scopeToClass, funcName);
|
|
1078
|
-
dispatchNamesCache.set(dnKey, dispatchNames);
|
|
1079
|
-
}
|
|
1080
|
-
instanceTypeMap = new Map();
|
|
1081
|
-
for (const dn of dispatchNames) {
|
|
1082
|
-
for (const [recv, cls] of _buildInstanceTypeMapForTracing(index, filePath, content, dn)) {
|
|
1083
|
-
if (!instanceTypeMap.has(recv)) instanceTypeMap.set(recv, cls);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
const seenLines = new Set();
|
|
1089
|
-
for (const usage of astUsages) {
|
|
1090
|
-
if (usage.usageType === 'definition') continue;
|
|
1091
|
-
// Inline-test-promoted file: only lines inside the
|
|
1092
|
-
// test ranges are test code (fix #244).
|
|
1093
|
-
if (testRanges && !lineInRanges(usage.line, testRanges)) continue;
|
|
1094
|
-
// The engine confirmed this exact site as a caller
|
|
1095
|
-
// edge — its receiver physics subsume every text
|
|
1096
|
-
// heuristic below (fix #246: exact-class scoping
|
|
1097
|
-
// rejected a non-overriding subclass receiver the
|
|
1098
|
-
// engine had confirmed).
|
|
1099
|
-
const engineConfirmedHere = engineFileSites ? engineFileSites.has(usage.line) : false;
|
|
1100
|
-
// Local same-name shadow: bare calls bind the test
|
|
1101
|
-
// file's OWN definition, never the affected symbol.
|
|
1102
|
-
if (localShadow && !engineConfirmedHere && !usage.receiver && usage.usageType !== 'import') continue;
|
|
1103
|
-
const lineKey = `${usage.line}:${usage.usageType}`;
|
|
1104
|
-
if (seenLines.has(lineKey)) continue;
|
|
1105
|
-
seenLines.add(lineKey);
|
|
1106
|
-
|
|
1107
|
-
let matchType;
|
|
1108
|
-
if (usage.usageType === 'import') {
|
|
1109
|
-
matchType = 'import';
|
|
1110
|
-
} else if (usage.usageType === 'call') {
|
|
1111
|
-
matchType = 'call';
|
|
1112
|
-
} else {
|
|
1113
|
-
matchType = 'reference';
|
|
1114
|
-
}
|
|
1115
|
-
// A confirmed usage-style edge (callback reference,
|
|
1116
|
-
// method value — fix #221) IS coverage: the test
|
|
1117
|
-
// executes the symbol through the reference.
|
|
1118
|
-
if (engineConfirmedHere && matchType === 'reference') matchType = 'call';
|
|
1119
|
-
|
|
1120
|
-
if (matchType === 'call' && !engineConfirmedHere && engineAdjudicated) {
|
|
1121
|
-
// The engine searched this name's callers and did
|
|
1122
|
-
// NOT confirm this site — the same payload's
|
|
1123
|
-
// account holds it excluded-with-reason or
|
|
1124
|
-
// unverified. Crediting it as confirmed coverage
|
|
1125
|
-
// would contradict the account (fix #246);
|
|
1126
|
-
// unverified sites surface in
|
|
1127
|
-
// possiblyAffectedTests via the frontier.
|
|
1128
|
-
continue;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
// className scoping for calls (text physics — only
|
|
1132
|
-
// depth-limit names reach this un-adjudicated)
|
|
1133
|
-
if (scopeToClass && matchType === 'call' && !engineConfirmedHere) {
|
|
1134
|
-
if (!_receiverMatchesClassTracing(usage, dispatchNames, instanceTypeMap,
|
|
1135
|
-
index.getLineContent(filePath, usage.line), funcName)) continue;
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
// className scoping for references: require class-associated receiver
|
|
1139
|
-
if (scopeToClass && matchType === 'reference') {
|
|
1140
|
-
if (!usage.receiver) continue;
|
|
1141
|
-
if (!dispatchNames.has(usage.receiver) &&
|
|
1142
|
-
!(instanceTypeMap && dispatchNames.has(instanceTypeMap.get(usage.receiver)))) {
|
|
1143
|
-
continue;
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
const lineContent = index.getLineContent(filePath, usage.line);
|
|
1148
|
-
if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
|
|
1149
|
-
fileMatches.get(funcName).push({
|
|
1150
|
-
line: usage.line, content: lineContent.trim(),
|
|
1151
|
-
matchType, functionName: funcName
|
|
1152
|
-
});
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
// Confirmed engine sites the text scan could not see —
|
|
1156
|
-
// renamed destructure aliases (`{ save: persist }`),
|
|
1157
|
-
// beyondText lines — are coverage too (fix #246). The
|
|
1158
|
-
// dedup map below collapses any line the loop already
|
|
1159
|
-
// matched (call outranks reference).
|
|
1160
|
-
if (engineFileSites) {
|
|
1161
|
-
for (const ln of [...engineFileSites].sort((a, b) => a - b)) {
|
|
1162
|
-
if (testRanges && !lineInRanges(ln, testRanges)) continue;
|
|
1163
|
-
const lineKey = `${ln}:call`;
|
|
1164
|
-
if (seenLines.has(lineKey)) continue;
|
|
1165
|
-
seenLines.add(lineKey);
|
|
1166
|
-
if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
|
|
1167
|
-
fileMatches.get(funcName).push({
|
|
1168
|
-
line: ln,
|
|
1169
|
-
content: (index.getLineContent(filePath, ln) || '').trim(),
|
|
1170
|
-
matchType: 'call', functionName: funcName
|
|
1171
|
-
});
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// Language-aware test-case detection
|
|
1176
|
-
_addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
if (fileMatches.size > 0) {
|
|
1180
|
-
const linkedFunctions = [...fileMatches.keys()];
|
|
1181
|
-
const allMatches = [];
|
|
1182
|
-
for (const matches of fileMatches.values()) allMatches.push(...matches);
|
|
1183
|
-
// Deduplicate same line+function (test-case line might overlap with call line)
|
|
1184
|
-
const dedupMap = new Map();
|
|
1185
|
-
for (const m of allMatches) {
|
|
1186
|
-
const key = `${m.line}:${m.functionName}`;
|
|
1187
|
-
const existing = dedupMap.get(key);
|
|
1188
|
-
if (!existing || _matchPriority(m.matchType) > _matchPriority(existing.matchType)) {
|
|
1189
|
-
dedupMap.set(key, m);
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
const deduped = [...dedupMap.values()].sort((a, b) => a.line - b.line);
|
|
1193
|
-
|
|
1194
|
-
// Only count functions with call or test-case matches as
|
|
1195
|
-
// statically linked. Import-only or reference-only records
|
|
1196
|
-
// are useful evidence, but are not an exercising path.
|
|
1197
|
-
const realLinkedAll = linkedFunctions.filter(fn => {
|
|
1198
|
-
const fnMatches = deduped.filter(m => m.functionName === fn);
|
|
1199
|
-
return fnMatches.some(m => m.matchType === 'call' || m.matchType === 'test-case');
|
|
1200
|
-
});
|
|
1201
|
-
const staticallyLinkedFunctions = realLinkedAll.filter(fn => affectedNames.has(fn));
|
|
1202
|
-
const possiblyLinkedFunctions = realLinkedAll.filter(fn => possiblyNames.has(fn));
|
|
1203
|
-
|
|
1204
|
-
if (staticallyLinkedFunctions.length > 0) {
|
|
1205
|
-
// Confirmed band: matches for confirmed-linked names
|
|
1206
|
-
const realMatches = deduped.filter(m =>
|
|
1207
|
-
affectedNames.has(m.functionName) &&
|
|
1208
|
-
(m.matchType === 'call' || m.matchType === 'test-case' ||
|
|
1209
|
-
staticallyLinkedFunctions.includes(m.functionName))
|
|
1210
|
-
);
|
|
1211
|
-
results.push({
|
|
1212
|
-
file: fileEntry.relativePath,
|
|
1213
|
-
linkedFunctions: staticallyLinkedFunctions,
|
|
1214
|
-
...(possiblyLinkedFunctions.length > 0 && { possiblyLinkedFunctions }),
|
|
1215
|
-
matchCount: realMatches.length,
|
|
1216
|
-
matches: realMatches
|
|
1217
|
-
});
|
|
1218
|
-
} else if (possiblyLinkedFunctions.length > 0) {
|
|
1219
|
-
// Possible band: file reaches the change only through
|
|
1220
|
-
// unverified chains.
|
|
1221
|
-
const possibleMatches = deduped.filter(m =>
|
|
1222
|
-
possiblyNames.has(m.functionName) &&
|
|
1223
|
-
(m.matchType === 'call' || m.matchType === 'test-case' ||
|
|
1224
|
-
possiblyLinkedFunctions.includes(m.functionName))
|
|
1225
|
-
);
|
|
1226
|
-
possibleResults.push({
|
|
1227
|
-
file: fileEntry.relativePath,
|
|
1228
|
-
linkedFunctions: possiblyLinkedFunctions,
|
|
1229
|
-
matchCount: possibleMatches.length,
|
|
1230
|
-
matches: possibleMatches
|
|
1231
|
-
});
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
} catch (e) { /* skip unreadable */ }
|
|
973
|
+
}
|
|
974
|
+
for (const n of affectedNames) possiblyNames.delete(n);
|
|
975
|
+
const results = finishRows(confirmedRows);
|
|
976
|
+
// Keep uncertainty even when the same file also has a confirmed
|
|
977
|
+
// link: its possible sites may belong to other test units.
|
|
978
|
+
const possibleResults = finishRows(possibleRows);
|
|
979
|
+
for (const row of results) {
|
|
980
|
+
const possible = possibleRows.get(row.file);
|
|
981
|
+
if (possible) row.possiblyLinkedFunctions = [...possible.linkedFunctions];
|
|
1235
982
|
}
|
|
1236
983
|
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
const existing = inResults || inPossible;
|
|
1249
|
-
const have = new Set([
|
|
1250
|
-
...existing.linkedFunctions,
|
|
1251
|
-
...(existing.possiblyLinkedFunctions || []),
|
|
1252
|
-
]);
|
|
1253
|
-
const extra = [...entry.byName.keys()].filter(n => !have.has(n)).sort(codeUnitCompare);
|
|
1254
|
-
if (extra.length > 0) {
|
|
1255
|
-
if (inResults) {
|
|
1256
|
-
existing.possiblyLinkedFunctions = [
|
|
1257
|
-
...(existing.possiblyLinkedFunctions || []),
|
|
1258
|
-
...extra,
|
|
1259
|
-
].sort(codeUnitCompare);
|
|
1260
|
-
} else {
|
|
1261
|
-
existing.linkedFunctions = [...existing.linkedFunctions, ...extra].sort(codeUnitCompare);
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
continue;
|
|
984
|
+
// What each file is to its runner, which tests hold the links, and
|
|
985
|
+
// how many tests that is.
|
|
986
|
+
const confirmedTests = new Set();
|
|
987
|
+
const conditionalTests = new Set();
|
|
988
|
+
const describeBand = (rows, confirmed) => {
|
|
989
|
+
const linesByFile = new Map();
|
|
990
|
+
for (const row of rows) {
|
|
991
|
+
const absFile = absoluteOf.get(row.file);
|
|
992
|
+
if (!absFile) continue;
|
|
993
|
+
linesByFile.set(absFile, row.matches
|
|
994
|
+
.filter(match => match.matchType === 'call' || match.matchType === 'test-case'));
|
|
1265
995
|
}
|
|
1266
|
-
const
|
|
1267
|
-
const
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
996
|
+
const byFile = new Map(rows.map(row => [row.file, row]));
|
|
997
|
+
for (const [absFile, linked] of linkFiles(model, index, linesByFile)) {
|
|
998
|
+
const relativePath = index.files.get(absFile).relativePath;
|
|
999
|
+
let row = byFile.get(relativePath);
|
|
1000
|
+
if (!row) {
|
|
1001
|
+
// The file of a class that inherits a linked test: the
|
|
1002
|
+
// test runs there, whatever file its body stands in.
|
|
1003
|
+
const sources = (linked.from || []).map(from => byFile.get(from)).filter(Boolean);
|
|
1004
|
+
row = {
|
|
1005
|
+
file: relativePath,
|
|
1006
|
+
linkedFunctions: [...new Set(sources.flatMap(source => source.linkedFunctions))]
|
|
1007
|
+
.sort(codeUnitCompare),
|
|
1008
|
+
matchCount: 0,
|
|
1009
|
+
matches: [],
|
|
1010
|
+
};
|
|
1011
|
+
byFile.set(relativePath, row);
|
|
1012
|
+
absoluteOf.set(relativePath, absFile);
|
|
1013
|
+
rows.push(row);
|
|
1014
|
+
}
|
|
1015
|
+
for (const unit of linked.units) {
|
|
1016
|
+
if (!unit.inheritedFrom) continue;
|
|
1017
|
+
row.matches.push({
|
|
1018
|
+
line: unit.line,
|
|
1019
|
+
content: (index.getLineContent(absFile, unit.line) || '').trim(),
|
|
1020
|
+
matchType: 'inherited-test',
|
|
1021
|
+
functionName: row.linkedFunctions[0] || name,
|
|
1022
|
+
name: unit.name,
|
|
1023
|
+
from: unit.inheritedFrom,
|
|
1275
1024
|
});
|
|
1025
|
+
row.matchCount = row.matches.length;
|
|
1026
|
+
}
|
|
1027
|
+
row.role = linked.role;
|
|
1028
|
+
if (linked.runner) row.runner = linked.runner;
|
|
1029
|
+
row.testCount = linked.tests;
|
|
1030
|
+
if (linked.moreTests) row.moreTests = true;
|
|
1031
|
+
if (linked.conditionalTests) row.conditionalTests = linked.conditionalTests;
|
|
1032
|
+
if (linked.units.length > 0) row.units = linked.units;
|
|
1033
|
+
if (linked.run) row.run = linked.run;
|
|
1034
|
+
else if (linked.runWithheld) row.runWithheld = linked.runWithheld;
|
|
1035
|
+
if (confirmed) {
|
|
1036
|
+
for (const key of linked.testKeys) confirmedTests.add(key);
|
|
1037
|
+
for (const key of linked.conditionalKeys) conditionalTests.add(key);
|
|
1276
1038
|
}
|
|
1277
1039
|
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
matchCount: matches.length,
|
|
1282
|
-
matches,
|
|
1283
|
-
});
|
|
1284
|
-
}
|
|
1040
|
+
};
|
|
1041
|
+
describeBand(results, true);
|
|
1042
|
+
describeBand(possibleResults, false);
|
|
1285
1043
|
|
|
1286
1044
|
// Sort by linkage breadth then alphabetically
|
|
1287
1045
|
results.sort((a, b) => b.linkedFunctions.length - a.linkedFunctions.length || codeUnitCompare(a.file, b.file));
|
|
@@ -1290,21 +1048,18 @@ function affectedTests(index, name, options = {}) {
|
|
|
1290
1048
|
// Compute static-linkage stats.
|
|
1291
1049
|
// Filter out test function names from affectedNames — they are callers,
|
|
1292
1050
|
// not production symbols that need test coverage.
|
|
1293
|
-
const isProductionName = (n) => {
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
for (const [, fe] of index.files) {
|
|
1299
|
-
if (isTestFile(fe.relativePath, fe.language)) continue;
|
|
1300
|
-
const langModule = getLanguageAdapter(fe.language);
|
|
1301
|
-
const kindOf = langModule?.getEntryPointKind;
|
|
1302
|
-
if (fe.symbols?.some(s => s.name === n && (!kindOf || kindOf(s) !== 'test'))) {
|
|
1303
|
-
return true;
|
|
1304
|
-
}
|
|
1051
|
+
const isProductionName = n => (index.symbols.get(n) || []).some(symbol => {
|
|
1052
|
+
const fe = index.files.get(symbol.file);
|
|
1053
|
+
if (!fe) return false;
|
|
1054
|
+
if (!testPaths.has(symbol.file)) {
|
|
1055
|
+
testPaths.set(symbol.file, isTestFile(fe.relativePath, fe.language));
|
|
1305
1056
|
}
|
|
1306
|
-
return false;
|
|
1307
|
-
|
|
1057
|
+
if (testPaths.get(symbol.file)) return false;
|
|
1058
|
+
const kindOf = getLanguageAdapter(fe.language)?.getEntryPointKind;
|
|
1059
|
+
if (kindOf && kindOf(symbol) === 'test') return false;
|
|
1060
|
+
const ranges = model.ranges.get(symbol.file) || [];
|
|
1061
|
+
return !lineInRanges(symbol.startLine, ranges);
|
|
1062
|
+
});
|
|
1308
1063
|
const productionNames = new Set();
|
|
1309
1064
|
for (const n of affectedNames) {
|
|
1310
1065
|
if (isProductionName(n)) productionNames.add(n);
|
|
@@ -1331,11 +1086,21 @@ function affectedTests(index, name, options = {}) {
|
|
|
1331
1086
|
possiblyAffected: possiblyProduction,
|
|
1332
1087
|
testFiles: results,
|
|
1333
1088
|
possiblyAffectedTests: possibleResults,
|
|
1089
|
+
// The commands that run the confirmed files, where a runner is
|
|
1090
|
+
// proven and its command is known.
|
|
1091
|
+
commands: commandsFor(model, results.filter(row => row.role === 'tests')
|
|
1092
|
+
.map(row => absoluteOf.get(row.file)).filter(Boolean)),
|
|
1334
1093
|
...(blastResult.account && { account: blastResult.account }),
|
|
1335
1094
|
treeAccount: blastResult.treeAccount,
|
|
1336
1095
|
summary: {
|
|
1337
1096
|
totalAffected: namesForCoverage.size,
|
|
1338
1097
|
totalTestFiles: results.length,
|
|
1098
|
+
totalTests: confirmedTests.size,
|
|
1099
|
+
// A file among them holds tests that are not listed: the
|
|
1100
|
+
// total is a lower bound.
|
|
1101
|
+
...(results.some(row => row.moreTests) && { moreTests: true }),
|
|
1102
|
+
...(conditionalTests.size > 0 && { conditionalTests: conditionalTests.size }),
|
|
1103
|
+
supportFiles: results.filter(row => row.role === 'support').length,
|
|
1339
1104
|
staticallyLinkedFunctions: linkedSet.size,
|
|
1340
1105
|
notStaticallyLinkedCount: notStaticallyLinked.length,
|
|
1341
1106
|
possiblyAffected: possiblyProduction.length,
|
|
@@ -1348,157 +1113,4 @@ function affectedTests(index, name, options = {}) {
|
|
|
1348
1113
|
} finally { index._endOp(); }
|
|
1349
1114
|
}
|
|
1350
1115
|
|
|
1351
|
-
/**
|
|
1352
|
-
* Add test-case matches for a function name in a test file (language-aware).
|
|
1353
|
-
* Only adds test-case when the test body has a call match (not just import/reference).
|
|
1354
|
-
*/
|
|
1355
|
-
function _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches) {
|
|
1356
|
-
const lang = fileEntry.language;
|
|
1357
|
-
const existingMatches = fileMatches.get(funcName) || [];
|
|
1358
|
-
const existingLines = new Set(existingMatches.map(m => m.line));
|
|
1359
|
-
|
|
1360
|
-
if (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') {
|
|
1361
|
-
const calls = getCachedCalls(index, filePath);
|
|
1362
|
-
if (!calls) return;
|
|
1363
|
-
const testFrameworkCalls = new Set(['describe', 'it', 'test', 'spec']);
|
|
1364
|
-
// Word-boundary match (fix #246): substring matched mid-word titles.
|
|
1365
|
-
const termPattern = new RegExp('(^|[^\\w$])' + escapeRegExp(funcName) + '([^\\w$]|$)');
|
|
1366
|
-
for (const call of calls) {
|
|
1367
|
-
if (!testFrameworkCalls.has(call.name)) continue;
|
|
1368
|
-
const lineContent = index.getLineContent(filePath, call.line);
|
|
1369
|
-
if (termPattern.test(lineContent) && !existingLines.has(call.line)) {
|
|
1370
|
-
// Only add test-case if a call match exists in the test body
|
|
1371
|
-
const endLine = _estimateTestBlockEndTracing(index, filePath, call.line);
|
|
1372
|
-
const hasCallMatch = existingMatches.some(m =>
|
|
1373
|
-
m.line >= call.line && m.line <= endLine &&
|
|
1374
|
-
m.matchType === 'call'
|
|
1375
|
-
);
|
|
1376
|
-
if (!hasCallMatch) continue;
|
|
1377
|
-
if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
|
|
1378
|
-
fileMatches.get(funcName).push({
|
|
1379
|
-
line: call.line, content: lineContent.trim(),
|
|
1380
|
-
matchType: 'test-case', functionName: funcName
|
|
1381
|
-
});
|
|
1382
|
-
existingLines.add(call.line);
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
} else {
|
|
1386
|
-
if (!fileEntry.symbols) return;
|
|
1387
|
-
try {
|
|
1388
|
-
const langModule = getLanguageAdapter(lang);
|
|
1389
|
-
if (!langModule) return;
|
|
1390
|
-
// Prefer the kinded predicate so we don't mis-tag fn main() / fn init()
|
|
1391
|
-
// (runtime entries) as test cases (BUG-CX). Fall back to isEntryPoint
|
|
1392
|
-
// for backward compat with language modules that haven't been migrated.
|
|
1393
|
-
const classify = langModule.getEntryPointKind
|
|
1394
|
-
? (s) => langModule.getEntryPointKind(s) === 'test'
|
|
1395
|
-
: langModule.isEntryPoint;
|
|
1396
|
-
if (!classify) return;
|
|
1397
|
-
for (const symbol of fileEntry.symbols) {
|
|
1398
|
-
if (!classify(symbol)) continue;
|
|
1399
|
-
// Only add test-case if a call match exists in the test body
|
|
1400
|
-
const hasCallInRange = existingMatches.some(m =>
|
|
1401
|
-
m.line >= symbol.startLine && m.line <= symbol.endLine &&
|
|
1402
|
-
m.matchType === 'call'
|
|
1403
|
-
);
|
|
1404
|
-
if (hasCallInRange && !existingLines.has(symbol.startLine)) {
|
|
1405
|
-
const lineContent = index.getLineContent(filePath, symbol.startLine);
|
|
1406
|
-
if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
|
|
1407
|
-
fileMatches.get(funcName).push({
|
|
1408
|
-
line: symbol.startLine, content: lineContent.trim(),
|
|
1409
|
-
matchType: 'test-case', functionName: funcName
|
|
1410
|
-
});
|
|
1411
|
-
existingLines.add(symbol.startLine);
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
} catch (e) { /* skip */ }
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
/**
|
|
1419
|
-
* Build instance type map for className scoping in affectedTests.
|
|
1420
|
-
* Same logic as _buildInstanceTypeMap in search.js.
|
|
1421
|
-
*/
|
|
1422
|
-
function _buildInstanceTypeMapForTracing(index, filePath, content, targetClassName) {
|
|
1423
|
-
const typeMap = new Map();
|
|
1424
|
-
const calls = getCachedCalls(index, filePath);
|
|
1425
|
-
if (calls) {
|
|
1426
|
-
for (const call of calls) {
|
|
1427
|
-
if (call.isMethod && call.receiver && call.receiverType === targetClassName) {
|
|
1428
|
-
typeMap.set(call.receiver, targetClassName);
|
|
1429
|
-
}
|
|
1430
|
-
if (call.name === targetClassName && !call.isMethod) {
|
|
1431
|
-
const lineContent = index.getLineContent(filePath, call.line);
|
|
1432
|
-
const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
|
|
1433
|
-
if (assignMatch) typeMap.set(assignMatch[1], targetClassName);
|
|
1434
|
-
}
|
|
1435
|
-
if (call.isMethod && call.receiver === targetClassName) {
|
|
1436
|
-
const lineContent = index.getLineContent(filePath, call.line);
|
|
1437
|
-
const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
|
|
1438
|
-
if (assignMatch) typeMap.set(assignMatch[1], targetClassName);
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
const classUsages = index._getCachedUsages(filePath, targetClassName);
|
|
1443
|
-
if (classUsages) {
|
|
1444
|
-
for (const u of classUsages) {
|
|
1445
|
-
if (u.usageType === 'import' || u.usageType === 'definition') continue;
|
|
1446
|
-
const lineContent = index.getLineContent(filePath, u.line);
|
|
1447
|
-
const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
|
|
1448
|
-
if (assignMatch && assignMatch[1] !== targetClassName) {
|
|
1449
|
-
typeMap.set(assignMatch[1], targetClassName);
|
|
1450
|
-
}
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
return typeMap;
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
/**
|
|
1457
|
-
* Check if a usage's receiver matches any of the target dispatch class names
|
|
1458
|
-
* (the class + its non-overriding descendants — fix #246) for affectedTests.
|
|
1459
|
-
* Same logic as _receiverMatchesClass in search.js.
|
|
1460
|
-
*/
|
|
1461
|
-
function _receiverMatchesClassTracing(usage, dispatchNames, instanceTypeMap, lineContent, searchTerm) {
|
|
1462
|
-
if (usage.receiver && dispatchNames.has(usage.receiver)) return true;
|
|
1463
|
-
if (usage.receiver && instanceTypeMap && dispatchNames.has(instanceTypeMap.get(usage.receiver))) return true;
|
|
1464
|
-
if (usage.receiver) return false;
|
|
1465
|
-
if (lineContent && searchTerm) {
|
|
1466
|
-
for (const className of dispatchNames) {
|
|
1467
|
-
const pat = new RegExp(
|
|
1468
|
-
'\\b' + escapeRegExp(className) + '\\s*(?:(?:\\([^)]*\\)|\\{[^}]*\\})\\s*\\.\\s*' +
|
|
1469
|
-
escapeRegExp(searchTerm) + '\\s*\\(|' +
|
|
1470
|
-
'new\\s+' + escapeRegExp(className) + '\\s*\\([^)]*\\)\\s*\\.\\s*' +
|
|
1471
|
-
escapeRegExp(searchTerm) + '\\s*\\()'
|
|
1472
|
-
);
|
|
1473
|
-
if (pat.test(lineContent)) return true;
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
return false;
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
/**
|
|
1480
|
-
* Estimate the end line of a test block by tracking brace/paren nesting.
|
|
1481
|
-
*/
|
|
1482
|
-
function _estimateTestBlockEndTracing(index, filePath, startLine) {
|
|
1483
|
-
const content = index._readFile(filePath);
|
|
1484
|
-
if (!content) return startLine + 5;
|
|
1485
|
-
const lines = content.split('\n');
|
|
1486
|
-
let depth = 0;
|
|
1487
|
-
let started = false;
|
|
1488
|
-
for (let i = startLine - 1; i < lines.length; i++) {
|
|
1489
|
-
const line = lines[i];
|
|
1490
|
-
for (const ch of line) {
|
|
1491
|
-
if (ch === '{' || ch === '(') { depth++; started = true; }
|
|
1492
|
-
else if (ch === '}' || ch === ')') { depth--; }
|
|
1493
|
-
}
|
|
1494
|
-
if (started && depth <= 0) return i + 1;
|
|
1495
|
-
}
|
|
1496
|
-
return Math.min(startLine + 10, lines.length);
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
function _matchPriority(matchType) {
|
|
1500
|
-
const p = { 'test-case': 5, 'call': 4, 'import': 3, 'string-ref': 2, 'reference': 1 };
|
|
1501
|
-
return p[matchType] || 0;
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
1116
|
module.exports = { trace, blast, reverseTrace, affectedTests };
|