ucn 4.0.2 → 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.
Files changed (46) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +322 -304
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +52 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/endpoints.js +8 -3
  21. package/core/output/extraction.js +12 -1
  22. package/core/output/find.js +23 -9
  23. package/core/output/graph.js +32 -9
  24. package/core/output/refactoring.js +147 -49
  25. package/core/output/reporting.js +104 -5
  26. package/core/output/search.js +30 -3
  27. package/core/output/shared.js +31 -4
  28. package/core/output/tracing.js +22 -11
  29. package/core/parser.js +8 -6
  30. package/core/project.js +167 -7
  31. package/core/registry.js +20 -16
  32. package/core/reporting.js +131 -13
  33. package/core/search.js +270 -55
  34. package/core/shared.js +240 -1
  35. package/core/stacktrace.js +23 -1
  36. package/core/tracing.js +278 -36
  37. package/core/verify.js +352 -349
  38. package/languages/go.js +29 -17
  39. package/languages/index.js +56 -0
  40. package/languages/java.js +133 -16
  41. package/languages/javascript.js +215 -27
  42. package/languages/python.js +113 -47
  43. package/languages/rust.js +89 -26
  44. package/languages/utils.js +35 -7
  45. package/mcp/server.js +69 -30
  46. 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 || '').localeCompare(b.atNode.file || '') ||
64
+ codeUnitCompare((a.atNode.file || ''), b.atNode.file || '') ||
65
65
  ((a.atNode.line || 0) - (b.atNode.line || 0)) ||
66
- (a.relativePath || '').localeCompare(b.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.localeCompare(b.def.file) || a.def.startLine - b.def.startLine
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
- isTest = fileEntry.symbols?.some(s =>
875
- s.modifiers?.includes('test') || s.modifiers?.includes('cfg_test_module')
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 (!astUsages || astUsages.length === 0) continue;
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
- // Build instance type map for className scoping (if applicable)
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
- if (className) {
895
- instanceTypeMap = _buildInstanceTypeMapForTracing(index, filePath, content, className);
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: check receiver
915
- if (className && matchType === 'call') {
916
- if (!_receiverMatchesClassTracing(usage, className, instanceTypeMap,
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 (className && matchType === 'reference') {
1092
+ if (scopeToClass && matchType === 'reference') {
922
1093
  if (!usage.receiver) continue;
923
- if (usage.receiver !== className &&
924
- !(instanceTypeMap && instanceTypeMap.get(usage.receiver) === className)) {
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.localeCompare(b.file));
1000
- possibleResults.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.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 (lineContent.includes(funcName) && !existingLines.has(call.line)) {
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 className (for affectedTests).
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, className, instanceTypeMap, lineContent, searchTerm) {
1166
- if (usage.receiver === className) return true;
1167
- if (usage.receiver && instanceTypeMap && instanceTypeMap.get(usage.receiver) === className) return true;
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 pat = new RegExp(
1171
- '\\b' + escapeRegExp(className) + '\\s*(?:(?:\\([^)]*\\)|\\{[^}]*\\})\\s*\\.\\s*' +
1172
- escapeRegExp(searchTerm) + '\\s*\\(|' +
1173
- 'new\\s+' + escapeRegExp(className) + '\\s*\\([^)]*\\)\\s*\\.\\s*' +
1174
- escapeRegExp(searchTerm) + '\\s*\\()'
1175
- );
1176
- if (pat.test(lineContent)) return true;
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
  }