ucn 4.0.1 → 4.1.0
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 -7
- package/README.md +89 -50
- package/cli/index.js +199 -94
- package/core/account.js +3 -1
- package/core/analysis.js +334 -316
- package/core/bridge.js +13 -8
- package/core/cache.js +109 -19
- package/core/callers.js +969 -77
- package/core/check.js +19 -8
- package/core/deadcode.js +368 -40
- package/core/discovery.js +31 -11
- package/core/entrypoints.js +149 -17
- package/core/execute.js +330 -43
- package/core/graph-build.js +61 -10
- package/core/graph.js +282 -61
- package/core/imports.js +72 -3
- package/core/output/analysis-ext.js +70 -10
- package/core/output/analysis.js +67 -33
- package/core/output/check.js +4 -1
- package/core/output/doctor.js +13 -3
- package/core/output/endpoints.js +8 -3
- package/core/output/extraction.js +12 -1
- package/core/output/find.js +23 -9
- package/core/output/graph.js +32 -9
- package/core/output/refactoring.js +147 -49
- package/core/output/reporting.js +104 -5
- package/core/output/search.js +30 -3
- package/core/output/shared.js +31 -4
- package/core/output/tracing.js +22 -11
- package/core/parser.js +8 -6
- package/core/project.js +167 -7
- package/core/registry.js +20 -16
- package/core/reporting.js +220 -84
- package/core/search.js +270 -55
- package/core/shared.js +285 -1
- package/core/stacktrace.js +23 -1
- package/core/tracing.js +278 -36
- package/core/verify.js +352 -349
- package/languages/go.js +29 -17
- package/languages/index.js +56 -0
- package/languages/java.js +133 -16
- package/languages/javascript.js +215 -27
- package/languages/python.js +113 -47
- package/languages/rust.js +89 -26
- package/languages/utils.js +35 -7
- package/mcp/server.js +72 -31
- package/package.json +4 -1
package/core/tracing.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
'use strict';
|
|
21
21
|
|
|
22
22
|
const path = require('path');
|
|
23
|
-
const { escapeRegExp } = require('./shared');
|
|
23
|
+
const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames } = require('./shared');
|
|
24
24
|
const { isTestFile } = require('./discovery');
|
|
25
25
|
const { getCachedCalls } = require('./callers');
|
|
26
26
|
const { detectLanguage, getLanguageModule } = require('../languages');
|
|
@@ -61,9 +61,9 @@ function _contractCallers(index, funcDef, { includeMethods, callerCache, pin })
|
|
|
61
61
|
function _sortFrontier(frontier) {
|
|
62
62
|
frontier.sort((a, b) =>
|
|
63
63
|
(a.hop - b.hop) ||
|
|
64
|
-
(a.atNode.file || '')
|
|
64
|
+
codeUnitCompare((a.atNode.file || ''), b.atNode.file || '') ||
|
|
65
65
|
((a.atNode.line || 0) - (b.atNode.line || 0)) ||
|
|
66
|
-
(a.relativePath || '')
|
|
66
|
+
codeUnitCompare((a.relativePath || ''), b.relativePath || '') ||
|
|
67
67
|
((a.line || 0) - (b.line || 0)));
|
|
68
68
|
return frontier;
|
|
69
69
|
}
|
|
@@ -126,7 +126,7 @@ function _resolveCallerEntries(index, callers, exclude) {
|
|
|
126
126
|
|
|
127
127
|
// Stable sort by file + line
|
|
128
128
|
callerEntries.sort((a, b) =>
|
|
129
|
-
a.def.file
|
|
129
|
+
codeUnitCompare(a.def.file, b.def.file) || a.def.startLine - b.def.startLine
|
|
130
130
|
);
|
|
131
131
|
return callerEntries;
|
|
132
132
|
}
|
|
@@ -330,6 +330,10 @@ function blast(index, name, options = {}) {
|
|
|
330
330
|
const includeMethods = options.includeMethods ?? true;
|
|
331
331
|
const exclude = options.exclude || [];
|
|
332
332
|
const expandUnverified = !!options.expandUnverified;
|
|
333
|
+
// Internal (affectedTests): observe which names had their callers
|
|
334
|
+
// searched and where the confirmed trunk edges sit, without changing
|
|
335
|
+
// the blast result shape (fix #246).
|
|
336
|
+
const collect = options._collect || null;
|
|
333
337
|
|
|
334
338
|
const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
|
|
335
339
|
if (!def) return null;
|
|
@@ -393,6 +397,7 @@ function blast(index, name, options = {}) {
|
|
|
393
397
|
treeAccount.nodesExpanded++;
|
|
394
398
|
_aggregateExcluded(treeAccount, raw);
|
|
395
399
|
if (currentDepth === 0) rootRaw = raw;
|
|
400
|
+
if (collect && !chainUnverified) collect.onExpand?.(funcDef);
|
|
396
401
|
|
|
397
402
|
// Confirmed-tier call sites form the trunk
|
|
398
403
|
let callers = confirmed;
|
|
@@ -403,6 +408,9 @@ function blast(index, name, options = {}) {
|
|
|
403
408
|
if (currentDepth === 0) rootFiltered += before - callers.length;
|
|
404
409
|
}
|
|
405
410
|
treeAccount.confirmedEdges += callers.length;
|
|
411
|
+
if (collect && !chainUnverified) {
|
|
412
|
+
for (const c of callers) collect.onConfirmed?.(funcDef, c);
|
|
413
|
+
}
|
|
406
414
|
|
|
407
415
|
// Unverified-tier candidates: visible frontier entries,
|
|
408
416
|
// expanded only under expandUnverified.
|
|
@@ -787,7 +795,15 @@ function affectedTests(index, name, options = {}) {
|
|
|
787
795
|
index._beginOp();
|
|
788
796
|
try {
|
|
789
797
|
const maxDepth = Math.max(0, options.depth ?? 3);
|
|
790
|
-
// Step 1: confirmed closure via blast (contract mode, no truncation)
|
|
798
|
+
// Step 1: confirmed closure via blast (contract mode, no truncation).
|
|
799
|
+
// The collector records, per trunk name, (a) whether the engine
|
|
800
|
+
// searched its callers and (b) where the confirmed edges sit — the
|
|
801
|
+
// coverage bands below must agree with that answer instead of
|
|
802
|
+
// re-deciding it with a text scan (fix #246: the scan credited call
|
|
803
|
+
// sites the same payload's account excluded, and missed confirmed
|
|
804
|
+
// edges text can't see — renamed destructure aliases, callback refs).
|
|
805
|
+
const expandedNames = new Set();
|
|
806
|
+
const confirmedSitesByName = new Map(); // name → Map(relPath → Set(line))
|
|
791
807
|
const blastResult = index.blast(name, {
|
|
792
808
|
depth: maxDepth,
|
|
793
809
|
file: options.file,
|
|
@@ -795,17 +811,37 @@ function affectedTests(index, name, options = {}) {
|
|
|
795
811
|
all: true,
|
|
796
812
|
exclude: options.exclude,
|
|
797
813
|
includeMethods: options.includeMethods,
|
|
814
|
+
_collect: {
|
|
815
|
+
onExpand: (funcDef) => expandedNames.add(funcDef.name),
|
|
816
|
+
onConfirmed: (funcDef, c) => {
|
|
817
|
+
let byFile = confirmedSitesByName.get(funcDef.name);
|
|
818
|
+
if (!byFile) { byFile = new Map(); confirmedSitesByName.set(funcDef.name, byFile); }
|
|
819
|
+
let lines = byFile.get(c.relativePath);
|
|
820
|
+
if (!lines) { lines = new Set(); byFile.set(c.relativePath, lines); }
|
|
821
|
+
lines.add(c.line);
|
|
822
|
+
},
|
|
823
|
+
},
|
|
798
824
|
});
|
|
799
825
|
if (!blastResult) return null;
|
|
800
826
|
|
|
801
|
-
// Step 2: Collect confirmed-affected function names (and node keys)
|
|
827
|
+
// Step 2: Collect confirmed-affected function names (and node keys).
|
|
828
|
+
// Also record each name's CLASS identity from the tree — interior
|
|
829
|
+
// names were scanned bare (post-#239), so a test of a DIFFERENT
|
|
830
|
+
// class's same-named method got credited as coverage (fix #244:
|
|
831
|
+
// `asyncio.run(...)` credited as covering Mgr.run).
|
|
802
832
|
const affectedNames = new Set();
|
|
803
833
|
const confirmedKeys = new Set();
|
|
834
|
+
const nameToClasses = new Map(); // name → Set(className|null)
|
|
804
835
|
affectedNames.add(name);
|
|
805
836
|
const collectNames = (node) => {
|
|
806
837
|
if (!node) return;
|
|
807
838
|
affectedNames.add(node.name);
|
|
808
839
|
confirmedKeys.add(`${node.file}:${node.line}`);
|
|
840
|
+
const defs = index.symbols.get(node.name);
|
|
841
|
+
const d = defs?.find(x => x.relativePath === node.file && x.startLine === node.line);
|
|
842
|
+
let set = nameToClasses.get(node.name);
|
|
843
|
+
if (!set) { set = new Set(); nameToClasses.set(node.name, set); }
|
|
844
|
+
set.add(d?.className || null);
|
|
809
845
|
for (const child of node.children || []) collectNames(child);
|
|
810
846
|
};
|
|
811
847
|
collectNames(blastResult.tree);
|
|
@@ -817,6 +853,10 @@ function affectedTests(index, name, options = {}) {
|
|
|
817
853
|
const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
|
|
818
854
|
const includeMethods = options.includeMethods ?? true;
|
|
819
855
|
const possiblyNames = new Set();
|
|
856
|
+
// Test FILES containing an unverified call site of an affected name
|
|
857
|
+
// (relativePath → funcName → Set(lines)) — merged into
|
|
858
|
+
// possiblyAffectedTests after the scan (fix #244).
|
|
859
|
+
const frontierTestHits = new Map();
|
|
820
860
|
{
|
|
821
861
|
const callerCache = new Map();
|
|
822
862
|
const possibleVisited = new Set();
|
|
@@ -840,6 +880,47 @@ function affectedTests(index, name, options = {}) {
|
|
|
840
880
|
};
|
|
841
881
|
for (const fe of blastResult.unverifiedFrontier || []) {
|
|
842
882
|
enqueueCaller(fe, fe.hop);
|
|
883
|
+
// A frontier caller that is ITSELF a test function landed in
|
|
884
|
+
// neither band (fix #244, the unittest setUp idiom):
|
|
885
|
+
// isProductionName filters it from possiblyAffected, and the
|
|
886
|
+
// test scan greps for calls OF it — tests are never called.
|
|
887
|
+
// Route its FILE into possiblyAffectedTests directly.
|
|
888
|
+
// Anonymous test callbacks (`it('...', () => { x.save() })`)
|
|
889
|
+
// have no enclosing NAMED caller at all — the site's file is
|
|
890
|
+
// still test evidence, so route by the site file whenever it
|
|
891
|
+
// is a test file (fix #246; the caller-file and site file are
|
|
892
|
+
// the same file by construction when both exist).
|
|
893
|
+
const siteFile = fe.callerFile || fe.file;
|
|
894
|
+
if (siteFile) {
|
|
895
|
+
const cfe = index.files.get(siteFile);
|
|
896
|
+
if (cfe) {
|
|
897
|
+
let isTestCaller = isTestFile(cfe.relativePath, cfe.language);
|
|
898
|
+
if (!isTestCaller && fe.callerName) {
|
|
899
|
+
const defs = index.symbols.get(fe.callerName);
|
|
900
|
+
const d = defs?.find(x => x.file === fe.callerFile && x.startLine === fe.callerStartLine);
|
|
901
|
+
const kindOf = getLanguageModule(cfe.language)?.getEntryPointKind;
|
|
902
|
+
if (d && kindOf && kindOf(d) === 'test') isTestCaller = true;
|
|
903
|
+
}
|
|
904
|
+
if (!isTestCaller && fe.line != null) {
|
|
905
|
+
// Inline #[cfg(test)] regions of production files
|
|
906
|
+
// are test code too — but only within the ranges.
|
|
907
|
+
const ranges = inlineTestRanges(cfe);
|
|
908
|
+
if (ranges.length > 0 && lineInRanges(fe.line, ranges)) isTestCaller = true;
|
|
909
|
+
}
|
|
910
|
+
if (isTestCaller &&
|
|
911
|
+
(excludeArr.length === 0 || index.matchesFilters(cfe.relativePath, { exclude: excludeArr }))) {
|
|
912
|
+
const affName = fe.atNode?.name || name;
|
|
913
|
+
let entry = frontierTestHits.get(siteFile);
|
|
914
|
+
if (!entry) {
|
|
915
|
+
entry = { rel: cfe.relativePath, byName: new Map() };
|
|
916
|
+
frontierTestHits.set(siteFile, entry);
|
|
917
|
+
}
|
|
918
|
+
let hitLines = entry.byName.get(affName);
|
|
919
|
+
if (!hitLines) { hitLines = new Set(); entry.byName.set(affName, hitLines); }
|
|
920
|
+
if (fe.line != null) hitLines.add(fe.line);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
843
924
|
}
|
|
844
925
|
while (queue.length > 0) {
|
|
845
926
|
const { def: d, depth } = queue.shift();
|
|
@@ -866,14 +947,20 @@ function affectedTests(index, name, options = {}) {
|
|
|
866
947
|
const results = [];
|
|
867
948
|
const possibleResults = [];
|
|
868
949
|
const scanNames = [...affectedNames, ...possiblyNames];
|
|
950
|
+
const dispatchNamesCache = new Map(); // `${class}:${name}` → Set(classNames)
|
|
869
951
|
for (const [filePath, fileEntry] of index.files) {
|
|
870
952
|
let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
|
|
871
953
|
// Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
|
|
872
|
-
// or symbols inside a #[cfg(test)] mod block (BUG-CY).
|
|
954
|
+
// or symbols inside a #[cfg(test)] mod block (BUG-CY). Such files
|
|
955
|
+
// are test code ONLY within the inline test ranges — production
|
|
956
|
+
// lines were counted as test matches (fix #244, false coverage).
|
|
957
|
+
let testRanges = null;
|
|
873
958
|
if (!isTest && fileEntry.language === 'rust') {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
959
|
+
const ranges = inlineTestRanges(fileEntry);
|
|
960
|
+
if (ranges.length > 0) {
|
|
961
|
+
isTest = true;
|
|
962
|
+
testRanges = ranges;
|
|
963
|
+
}
|
|
877
964
|
}
|
|
878
965
|
if (!isTest) continue;
|
|
879
966
|
if (excludeArr.length > 0 && !index.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
|
|
@@ -882,22 +969,90 @@ function affectedTests(index, name, options = {}) {
|
|
|
882
969
|
const fileMatches = new Map();
|
|
883
970
|
|
|
884
971
|
for (const funcName of scanNames) {
|
|
972
|
+
// Confirmed trunk edges of this name in THIS file — the
|
|
973
|
+
// engine's answer for the sites the text scan is about
|
|
974
|
+
// to re-derive (fix #246).
|
|
975
|
+
const engineFileSites = confirmedSitesByName.get(funcName)?.get(fileEntry.relativePath) || null;
|
|
976
|
+
// Whether the engine searched this name's callers at all
|
|
977
|
+
// (depth-limit nodes were never adjudicated — the text
|
|
978
|
+
// scan remains their best-effort fallback).
|
|
979
|
+
const engineAdjudicated = expandedNames.has(funcName);
|
|
980
|
+
|
|
885
981
|
// Fast pre-check
|
|
886
|
-
if (!content.includes(funcName)) continue;
|
|
982
|
+
if (!content.includes(funcName) && !engineFileSites) continue;
|
|
887
983
|
|
|
888
984
|
// AST-based usage detection
|
|
889
|
-
const astUsages = index._getCachedUsages(filePath, funcName);
|
|
890
|
-
if (
|
|
985
|
+
const astUsages = index._getCachedUsages(filePath, funcName) || [];
|
|
986
|
+
if (astUsages.length === 0 && !engineFileSites) continue;
|
|
987
|
+
|
|
988
|
+
// className scoping: the user's pin applies to the ROOT
|
|
989
|
+
// symbol's own name only (fix #239, G3-go-measured) — a
|
|
990
|
+
// bare call to a standalone wrapper (SaveAll) can never
|
|
991
|
+
// carry the root's class receiver. INTERIOR names with a
|
|
992
|
+
// single unambiguous class identity in the blast tree
|
|
993
|
+
// scope to it (fix #244): a test of a different class's
|
|
994
|
+
// same-named method is not coverage of THIS closure.
|
|
995
|
+
// Standalone/mixed-identity names keep the bare scan.
|
|
996
|
+
let scopeToClass = null;
|
|
997
|
+
if (funcName === name) {
|
|
998
|
+
scopeToClass = className || null;
|
|
999
|
+
} else {
|
|
1000
|
+
const classes = nameToClasses.get(funcName);
|
|
1001
|
+
if (classes && classes.size === 1) {
|
|
1002
|
+
const only = classes.values().next().value;
|
|
1003
|
+
if (only) scopeToClass = only;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
891
1006
|
|
|
892
|
-
//
|
|
1007
|
+
// A test file that DEFINES funcName itself owns bare-name
|
|
1008
|
+
// calls of it (#215/#222(3) scope physics — fix #244: a
|
|
1009
|
+
// private same-name helper let the coverage band claim
|
|
1010
|
+
// 100% while the same payload's account excluded the
|
|
1011
|
+
// site as other-definition). Exception: when the local
|
|
1012
|
+
// def IS part of the blast closure (Rust inline-test
|
|
1013
|
+
// files define the symbol AND test it), bare calls bind
|
|
1014
|
+
// the affected symbol — genuine coverage.
|
|
1015
|
+
const localDefs = (fileEntry.symbols || []).filter(s => s.name === funcName);
|
|
1016
|
+
const localShadow = localDefs.length > 0 &&
|
|
1017
|
+
localDefs.every(s => !confirmedKeys.has(`${fileEntry.relativePath}:${s.startLine}`)) &&
|
|
1018
|
+
!(fileEntry.importBindings || []).some(b => b.name === funcName);
|
|
1019
|
+
|
|
1020
|
+
// Build instance type map for className scoping (if applicable).
|
|
1021
|
+
// Scoping accepts the class plus its non-overriding
|
|
1022
|
+
// descendants — instances of a subclass that doesn't
|
|
1023
|
+
// override funcName dispatch to the target (fix #246).
|
|
893
1024
|
let instanceTypeMap = null;
|
|
894
|
-
|
|
895
|
-
|
|
1025
|
+
let dispatchNames = null;
|
|
1026
|
+
if (scopeToClass) {
|
|
1027
|
+
const dnKey = `${scopeToClass}:${funcName}`;
|
|
1028
|
+
dispatchNames = dispatchNamesCache.get(dnKey);
|
|
1029
|
+
if (!dispatchNames) {
|
|
1030
|
+
dispatchNames = classDispatchNames(index, scopeToClass, funcName);
|
|
1031
|
+
dispatchNamesCache.set(dnKey, dispatchNames);
|
|
1032
|
+
}
|
|
1033
|
+
instanceTypeMap = new Map();
|
|
1034
|
+
for (const dn of dispatchNames) {
|
|
1035
|
+
for (const [recv, cls] of _buildInstanceTypeMapForTracing(index, filePath, content, dn)) {
|
|
1036
|
+
if (!instanceTypeMap.has(recv)) instanceTypeMap.set(recv, cls);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
896
1039
|
}
|
|
897
1040
|
|
|
898
1041
|
const seenLines = new Set();
|
|
899
1042
|
for (const usage of astUsages) {
|
|
900
1043
|
if (usage.usageType === 'definition') continue;
|
|
1044
|
+
// Inline-test-promoted file: only lines inside the
|
|
1045
|
+
// test ranges are test code (fix #244).
|
|
1046
|
+
if (testRanges && !lineInRanges(usage.line, testRanges)) continue;
|
|
1047
|
+
// The engine confirmed this exact site as a caller
|
|
1048
|
+
// edge — its receiver physics subsume every text
|
|
1049
|
+
// heuristic below (fix #246: exact-class scoping
|
|
1050
|
+
// rejected a non-overriding subclass receiver the
|
|
1051
|
+
// engine had confirmed).
|
|
1052
|
+
const engineConfirmedHere = engineFileSites ? engineFileSites.has(usage.line) : false;
|
|
1053
|
+
// Local same-name shadow: bare calls bind the test
|
|
1054
|
+
// file's OWN definition, never the affected symbol.
|
|
1055
|
+
if (localShadow && !engineConfirmedHere && !usage.receiver && usage.usageType !== 'import') continue;
|
|
901
1056
|
const lineKey = `${usage.line}:${usage.usageType}`;
|
|
902
1057
|
if (seenLines.has(lineKey)) continue;
|
|
903
1058
|
seenLines.add(lineKey);
|
|
@@ -910,18 +1065,34 @@ function affectedTests(index, name, options = {}) {
|
|
|
910
1065
|
} else {
|
|
911
1066
|
matchType = 'reference';
|
|
912
1067
|
}
|
|
1068
|
+
// A confirmed usage-style edge (callback reference,
|
|
1069
|
+
// method value — fix #221) IS coverage: the test
|
|
1070
|
+
// executes the symbol through the reference.
|
|
1071
|
+
if (engineConfirmedHere && matchType === 'reference') matchType = 'call';
|
|
1072
|
+
|
|
1073
|
+
if (matchType === 'call' && !engineConfirmedHere && engineAdjudicated) {
|
|
1074
|
+
// The engine searched this name's callers and did
|
|
1075
|
+
// NOT confirm this site — the same payload's
|
|
1076
|
+
// account holds it excluded-with-reason or
|
|
1077
|
+
// unverified. Crediting it as confirmed coverage
|
|
1078
|
+
// would contradict the account (fix #246);
|
|
1079
|
+
// unverified sites surface in
|
|
1080
|
+
// possiblyAffectedTests via the frontier.
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
913
1083
|
|
|
914
|
-
// className scoping for calls
|
|
915
|
-
|
|
916
|
-
|
|
1084
|
+
// className scoping for calls (text physics — only
|
|
1085
|
+
// depth-limit names reach this un-adjudicated)
|
|
1086
|
+
if (scopeToClass && matchType === 'call' && !engineConfirmedHere) {
|
|
1087
|
+
if (!_receiverMatchesClassTracing(usage, dispatchNames, instanceTypeMap,
|
|
917
1088
|
index.getLineContent(filePath, usage.line), funcName)) continue;
|
|
918
1089
|
}
|
|
919
1090
|
|
|
920
1091
|
// className scoping for references: require class-associated receiver
|
|
921
|
-
if (
|
|
1092
|
+
if (scopeToClass && matchType === 'reference') {
|
|
922
1093
|
if (!usage.receiver) continue;
|
|
923
|
-
if (usage.receiver
|
|
924
|
-
!(instanceTypeMap && instanceTypeMap.get(usage.receiver)
|
|
1094
|
+
if (!dispatchNames.has(usage.receiver) &&
|
|
1095
|
+
!(instanceTypeMap && dispatchNames.has(instanceTypeMap.get(usage.receiver)))) {
|
|
925
1096
|
continue;
|
|
926
1097
|
}
|
|
927
1098
|
}
|
|
@@ -934,6 +1105,26 @@ function affectedTests(index, name, options = {}) {
|
|
|
934
1105
|
});
|
|
935
1106
|
}
|
|
936
1107
|
|
|
1108
|
+
// Confirmed engine sites the text scan could not see —
|
|
1109
|
+
// renamed destructure aliases (`{ save: persist }`),
|
|
1110
|
+
// beyondText lines — are coverage too (fix #246). The
|
|
1111
|
+
// dedup map below collapses any line the loop already
|
|
1112
|
+
// matched (call outranks reference).
|
|
1113
|
+
if (engineFileSites) {
|
|
1114
|
+
for (const ln of [...engineFileSites].sort((a, b) => a - b)) {
|
|
1115
|
+
if (testRanges && !lineInRanges(ln, testRanges)) continue;
|
|
1116
|
+
const lineKey = `${ln}:call`;
|
|
1117
|
+
if (seenLines.has(lineKey)) continue;
|
|
1118
|
+
seenLines.add(lineKey);
|
|
1119
|
+
if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
|
|
1120
|
+
fileMatches.get(funcName).push({
|
|
1121
|
+
line: ln,
|
|
1122
|
+
content: (index.getLineContent(filePath, ln) || '').trim(),
|
|
1123
|
+
matchType: 'call', functionName: funcName
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
937
1128
|
// Language-aware test-case detection
|
|
938
1129
|
_addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches);
|
|
939
1130
|
}
|
|
@@ -995,9 +1186,55 @@ function affectedTests(index, name, options = {}) {
|
|
|
995
1186
|
} catch (e) { /* skip unreadable */ }
|
|
996
1187
|
}
|
|
997
1188
|
|
|
1189
|
+
// Merge frontier test hits: a test file whose own test function IS
|
|
1190
|
+
// the unverified caller never surfaces via the scan (the scan greps
|
|
1191
|
+
// for calls of scanNames; the test's name is not among them) — add
|
|
1192
|
+
// it to the possible band with its unverified call sites (fix #244).
|
|
1193
|
+
// A file already present for OTHER names still gets this name listed
|
|
1194
|
+
// as possibly covered (fix #246 — the file-level dedup silently
|
|
1195
|
+
// dropped per-name possible coverage).
|
|
1196
|
+
for (const [absFile, entry] of frontierTestHits) {
|
|
1197
|
+
const inResults = results.find(r => r.file === entry.rel);
|
|
1198
|
+
const inPossible = possibleResults.find(r => r.file === entry.rel);
|
|
1199
|
+
if (inResults || inPossible) {
|
|
1200
|
+
const existing = inResults || inPossible;
|
|
1201
|
+
const have = new Set([
|
|
1202
|
+
...existing.coveredFunctions,
|
|
1203
|
+
...(existing.possiblyCovered || []),
|
|
1204
|
+
]);
|
|
1205
|
+
const extra = [...entry.byName.keys()].filter(n => !have.has(n)).sort(codeUnitCompare);
|
|
1206
|
+
if (extra.length > 0) {
|
|
1207
|
+
if (inResults) {
|
|
1208
|
+
existing.possiblyCovered = [...(existing.possiblyCovered || []), ...extra].sort(codeUnitCompare);
|
|
1209
|
+
} else {
|
|
1210
|
+
existing.coveredFunctions = [...existing.coveredFunctions, ...extra].sort(codeUnitCompare);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
const covered = [...entry.byName.keys()].sort(codeUnitCompare);
|
|
1216
|
+
const matches = [];
|
|
1217
|
+
for (const fn of covered) {
|
|
1218
|
+
for (const ln of [...entry.byName.get(fn)].sort((a, b) => a - b)) {
|
|
1219
|
+
matches.push({
|
|
1220
|
+
line: ln,
|
|
1221
|
+
content: (index.getLineContent(absFile, ln) || '').trim(),
|
|
1222
|
+
matchType: 'call',
|
|
1223
|
+
functionName: fn,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
possibleResults.push({
|
|
1228
|
+
file: entry.rel,
|
|
1229
|
+
coveredFunctions: covered,
|
|
1230
|
+
matchCount: matches.length,
|
|
1231
|
+
matches,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
998
1235
|
// Sort by coverage breadth then alphabetically
|
|
999
|
-
results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file
|
|
1000
|
-
possibleResults.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file
|
|
1236
|
+
results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || codeUnitCompare(a.file, b.file));
|
|
1237
|
+
possibleResults.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || codeUnitCompare(a.file, b.file));
|
|
1001
1238
|
|
|
1002
1239
|
// Compute coverage stats.
|
|
1003
1240
|
// Filter out test function names from affectedNames — they are callers,
|
|
@@ -1068,10 +1305,12 @@ function _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches
|
|
|
1068
1305
|
const calls = getCachedCalls(index, filePath);
|
|
1069
1306
|
if (!calls) return;
|
|
1070
1307
|
const testFrameworkCalls = new Set(['describe', 'it', 'test', 'spec']);
|
|
1308
|
+
// Word-boundary match (fix #246): substring matched mid-word titles.
|
|
1309
|
+
const termPattern = new RegExp('(^|[^\\w$])' + escapeRegExp(funcName) + '([^\\w$]|$)');
|
|
1071
1310
|
for (const call of calls) {
|
|
1072
1311
|
if (!testFrameworkCalls.has(call.name)) continue;
|
|
1073
1312
|
const lineContent = index.getLineContent(filePath, call.line);
|
|
1074
|
-
if (
|
|
1313
|
+
if (termPattern.test(lineContent) && !existingLines.has(call.line)) {
|
|
1075
1314
|
// Only add test-case if a call match exists in the test body
|
|
1076
1315
|
const endLine = _estimateTestBlockEndTracing(index, filePath, call.line);
|
|
1077
1316
|
const hasCallMatch = existingMatches.some(m =>
|
|
@@ -1159,21 +1398,24 @@ function _buildInstanceTypeMapForTracing(index, filePath, content, targetClassNa
|
|
|
1159
1398
|
}
|
|
1160
1399
|
|
|
1161
1400
|
/**
|
|
1162
|
-
* Check if a usage's receiver matches the target
|
|
1401
|
+
* Check if a usage's receiver matches any of the target dispatch class names
|
|
1402
|
+
* (the class + its non-overriding descendants — fix #246) for affectedTests.
|
|
1163
1403
|
* Same logic as _receiverMatchesClass in search.js.
|
|
1164
1404
|
*/
|
|
1165
|
-
function _receiverMatchesClassTracing(usage,
|
|
1166
|
-
if (usage.receiver
|
|
1167
|
-
if (usage.receiver && instanceTypeMap && instanceTypeMap.get(usage.receiver)
|
|
1405
|
+
function _receiverMatchesClassTracing(usage, dispatchNames, instanceTypeMap, lineContent, searchTerm) {
|
|
1406
|
+
if (usage.receiver && dispatchNames.has(usage.receiver)) return true;
|
|
1407
|
+
if (usage.receiver && instanceTypeMap && dispatchNames.has(instanceTypeMap.get(usage.receiver))) return true;
|
|
1168
1408
|
if (usage.receiver) return false;
|
|
1169
1409
|
if (lineContent && searchTerm) {
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1410
|
+
for (const className of dispatchNames) {
|
|
1411
|
+
const pat = new RegExp(
|
|
1412
|
+
'\\b' + escapeRegExp(className) + '\\s*(?:(?:\\([^)]*\\)|\\{[^}]*\\})\\s*\\.\\s*' +
|
|
1413
|
+
escapeRegExp(searchTerm) + '\\s*\\(|' +
|
|
1414
|
+
'new\\s+' + escapeRegExp(className) + '\\s*\\([^)]*\\)\\s*\\.\\s*' +
|
|
1415
|
+
escapeRegExp(searchTerm) + '\\s*\\()'
|
|
1416
|
+
);
|
|
1417
|
+
if (pat.test(lineContent)) return true;
|
|
1418
|
+
}
|
|
1177
1419
|
}
|
|
1178
1420
|
return false;
|
|
1179
1421
|
}
|