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.
Files changed (77) hide show
  1. package/.claude/skills/ucn/SKILL.md +37 -12
  2. package/.claude/skills/ucn/references/commands.md +18 -14
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +485 -0
  6. package/README.md +66 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +137 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +274 -108
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  13. package/core/cache.js +103 -16
  14. package/core/call-fit.js +130 -0
  15. package/core/callers.js +353 -71
  16. package/core/capture.js +363 -0
  17. package/core/change-kinds.js +1107 -0
  18. package/core/check.js +1150 -139
  19. package/core/command-contracts.js +8 -7
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +455 -106
  23. package/core/deletion.js +421 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoint-toml.js +110 -0
  26. package/core/entrypoints.js +12 -6
  27. package/core/evidence-bands.js +68 -0
  28. package/core/evidence-bands.json +44 -0
  29. package/core/execute.js +172 -161
  30. package/core/hot-summary.js +241 -0
  31. package/core/index-ir.js +5 -3
  32. package/core/ir.js +6 -2
  33. package/core/member-signatures.js +789 -0
  34. package/core/outgoing-check.js +116 -0
  35. package/core/output/analysis-ext.js +20 -10
  36. package/core/output/analysis.js +98 -60
  37. package/core/output/check.js +101 -37
  38. package/core/output/doctor.js +34 -1
  39. package/core/output/find.js +2 -1
  40. package/core/output/lines.js +63 -3
  41. package/core/output/public.js +29 -11
  42. package/core/output/refactoring.js +32 -0
  43. package/core/output/reporting.js +19 -2
  44. package/core/output/search.js +14 -2
  45. package/core/output/tracing.js +36 -8
  46. package/core/output-budget.js +146 -61
  47. package/core/parallel-build.js +3 -3
  48. package/core/project.js +106 -23
  49. package/core/provenance-type.js +12 -5
  50. package/core/public-command.js +35 -9
  51. package/core/python-binding-identity.js +707 -0
  52. package/core/python-modules.js +6 -0
  53. package/core/reflection.js +248 -31
  54. package/core/registry.js +623 -72
  55. package/core/reporting.js +69 -34
  56. package/core/review-items.js +75 -0
  57. package/core/runtime-entrypoints.js +159 -0
  58. package/core/search.js +148 -164
  59. package/core/shared.js +0 -45
  60. package/core/snapshot.js +350 -0
  61. package/core/source-universe.js +707 -0
  62. package/core/test-units.js +3698 -0
  63. package/core/tracing.js +270 -658
  64. package/core/verify.js +63 -22
  65. package/languages/adapter.js +8 -3
  66. package/languages/c-family.js +12 -0
  67. package/languages/csharp.js +634 -0
  68. package/languages/go.js +120 -0
  69. package/languages/index.js +466 -4
  70. package/languages/java.js +712 -0
  71. package/languages/javascript.js +1498 -6
  72. package/languages/lexical-scope.js +68 -0
  73. package/languages/python.js +1708 -3
  74. package/languages/rust.js +209 -104
  75. package/languages/static-js.js +88 -0
  76. package/mcp/server.js +44 -110
  77. package/package.json +11 -5
package/core/search.js CHANGED
@@ -17,6 +17,8 @@ const { isAccessorDefinition, findAccessorReferences } = require('./accessors');
17
17
  const isSafeRegex = require('safe-regex2');
18
18
  const { RE2JS } = require('re2js');
19
19
  const { UcnError } = require('./errors');
20
+ const { paramValues } = require('./registry');
21
+ const { createTestModel, addTestCaseRows, linkFiles, testSite, testSiteKey } = require('./test-units');
20
22
 
21
23
  // Keep the established search-input contract for the canonical catastrophic
22
24
  // nested-single-atom family even though the RE2-compatible engine could run it
@@ -36,11 +38,8 @@ function buildGlobMatcher(pattern, caseSensitive) {
36
38
  return (name) => regex.test(name);
37
39
  }
38
40
 
39
- const STRUCTURAL_TYPES = new Set([
40
- 'function', 'class', 'call', 'method', 'type', 'constructor',
41
- 'state', 'field', 'constant', 'macro', 'variable',
42
- 'interface', 'enum', 'struct', 'trait', 'record', 'namespace',
43
- ]);
41
+ // The kinds a structural search takes: the parameter table's set.
42
+ const STRUCTURAL_TYPES = new Set(paramValues('type', 'search'));
44
43
 
45
44
  /**
46
45
  * Substring match. Case-insensitive by default.
@@ -209,14 +208,18 @@ function _applyFindFilters(index, matches, options) {
209
208
 
210
209
  // Rank cheaply before adjudicating callers. A broad inventory must never
211
210
  // pay for pinned caller resolution on rows the caller will not receive.
211
+ const definitionOrder = (a, b) =>
212
+ (b._fuzzyScore || 0) - (a._fuzzyScore || 0) ||
213
+ Number(isTestFile(a.relativePath || '', a.language)) -
214
+ Number(isTestFile(b.relativePath || '', b.language)) ||
215
+ codeUnitCompare(a.relativePath || '', b.relativePath || '') ||
216
+ a.startLine - b.startLine || codeUnitCompare(a.name, b.name);
212
217
  const ranked = filtered.map(symbol => ({
213
218
  symbol, counts: index.countSymbolUsages(symbol),
214
219
  }));
215
220
  if (options.limit > 0 && ranked.length > options.limit) {
216
221
  ranked.sort((a, b) => b.counts.total - a.counts.total ||
217
- codeUnitCompare(a.symbol.relativePath || '', b.symbol.relativePath || '') ||
218
- a.symbol.startLine - b.symbol.startLine ||
219
- codeUnitCompare(a.symbol.name, b.symbol.name));
222
+ definitionOrder(a.symbol, b.symbol));
220
223
  }
221
224
  const survivors = options.limit > 0 ? ranked.slice(0, options.limit) : ranked;
222
225
  const withCounts = survivors.map(({ symbol: m, counts }) => {
@@ -258,7 +261,7 @@ function _applyFindFilters(index, matches, options) {
258
261
  });
259
262
 
260
263
  // Sort by usage count (most-used first)
261
- withCounts.sort((a, b) => b.usageCount - a.usageCount);
264
+ withCounts.sort((a, b) => b.usageCount - a.usageCount || definitionOrder(a, b));
262
265
 
263
266
  return withCounts;
264
267
  }
@@ -1313,6 +1316,10 @@ function tests(index, nameOrFile, options = {}) {
1313
1316
  index._beginOp();
1314
1317
  try {
1315
1318
  const results = [];
1319
+ const model = createTestModel(index);
1320
+ // Files where a title that names the term is a link (the term on a test
1321
+ // line is the project's symbol, not the file's own helper).
1322
+ const titled = new Set();
1316
1323
 
1317
1324
  // Check if it's a file path
1318
1325
  const isFilePath = nameOrFile.includes('/') || nameOrFile.includes('\\') ||
@@ -1325,7 +1332,7 @@ function tests(index, nameOrFile, options = {}) {
1325
1332
  let sourceFileFilter = null;
1326
1333
  let targetDefs = [];
1327
1334
  if (options.file && !isFilePath) {
1328
- targetDefs = index.find(nameOrFile, { exact: true, file: options.file, className: options.className });
1335
+ targetDefs = index.find(nameOrFile, { exact: true, file: options.file, className: options.className, skipCounts: true });
1329
1336
  if (targetDefs.length > 0) {
1330
1337
  sourceFileFilter = _buildSourceFileImporters(index, targetDefs);
1331
1338
  }
@@ -1333,12 +1340,11 @@ function tests(index, nameOrFile, options = {}) {
1333
1340
  // The execute handler validates before calling, so this path means
1334
1341
  // the file matched but no exact symbol — fall through gracefully.
1335
1342
  } else if (!isFilePath) {
1336
- targetDefs = index.find(nameOrFile, { exact: true, className: options.className });
1343
+ targetDefs = index.find(nameOrFile, { exact: true, className: options.className, skipCounts: true });
1337
1344
  }
1338
1345
  // A pinned definition among same-name ones (an overload, a constructor
1339
1346
  // beside its class and siblings, fix #430): calls are this definition's
1340
1347
  // only when the caller engine attributes them to it.
1341
- const pinnedFamily = !isFilePath && options.definition && targetDefs.length > 1;
1342
1348
  if (!isFilePath && options.definition) targetDefs = [options.definition];
1343
1349
 
1344
1350
  // Find all test files
@@ -1358,9 +1364,50 @@ function tests(index, nameOrFile, options = {}) {
1358
1364
  }
1359
1365
  }
1360
1366
 
1367
+ // A file's name does not decide what a test executes: the body of a
1368
+ // test a class inherits stands where its base class is, and a runner
1369
+ // takes tests from files of any name. A call site the caller engine
1370
+ // finds in such a file is a test's where the test model says a test
1371
+ // executes that line (core/test-units.js): the file joins the test
1372
+ // files, test code within those lines.
1373
+ const testInfoByPath = new Map(testFiles.map(info => [info.path, info]));
1374
+ const testInfoOf = filePath => {
1375
+ if (testInfoByPath.has(filePath)) return testInfoByPath.get(filePath);
1376
+ const entry = index.files.get(filePath);
1377
+ const ranges = entry ? model.testRangesOf(filePath) : [];
1378
+ const info = ranges.length > 0 ? { path: filePath, entry, testRanges: ranges } : null;
1379
+ testInfoByPath.set(filePath, info);
1380
+ if (info) testFiles.push(info);
1381
+ return info;
1382
+ };
1383
+
1361
1384
  const searchTerm = isFilePath
1362
1385
  ? path.basename(nameOrFile, path.extname(nameOrFile))
1363
1386
  : nameOrFile;
1387
+ const callerSites = [];
1388
+ const excludedCallableLines = new Map();
1389
+ const globallyUniqueTarget = !isFilePath && targetDefs.length === 1;
1390
+ if (!isFilePath) for (const def of targetDefs) {
1391
+ const raw = index.findCallers(searchTerm, {
1392
+ targetDefinitions: [def], collectAccount: true, unverifiedEnrichLimit: Infinity,
1393
+ });
1394
+ callerSites.push(
1395
+ ...raw.map(site => ({ ...site, _testEvidenceTier: 'confirmed' })),
1396
+ ...(globallyUniqueTarget ? (raw.unverifiedEntries || []).map(site => ({
1397
+ ...site, _testEvidenceTier: 'unverified',
1398
+ })) : []),
1399
+ );
1400
+ const counted = new Set();
1401
+ for (const site of raw.accountRaw?.excludedEntries || []) {
1402
+ const key = `${site.file}:${site.line}`;
1403
+ if (counted.has(key)) continue;
1404
+ counted.add(key);
1405
+ let lines = excludedCallableLines.get(site.file);
1406
+ if (!lines) { lines = new Map(); excludedCallableLines.set(site.file, lines); }
1407
+ lines.set(site.line, (lines.get(site.line) || 0) + 1);
1408
+ }
1409
+ }
1410
+ const acceptedCallableLines = new Set(callerSites.map(site => `${site.file}:${site.line}`));
1364
1411
 
1365
1412
  // File-path target: module specifiers are string literals, so the
1366
1413
  // basename usage scan is blind to import-linked tests in JS/TS
@@ -1388,7 +1435,6 @@ function tests(index, nameOrFile, options = {}) {
1388
1435
  // module-level coverage remain visible.
1389
1436
  const fileTargetCallerSites = new Map(); // absolute test file → tiered sites
1390
1437
  if (isFilePath && targetRels) {
1391
- const testFileInfo = new Map(testFiles.map(info => [info.path, info]));
1392
1438
  const fileTargetDefs = [];
1393
1439
  for (const [, fe] of index.files) {
1394
1440
  if (!targetRels.has(fe.relativePath)) continue;
@@ -1401,7 +1447,7 @@ function tests(index, nameOrFile, options = {}) {
1401
1447
  targetDefinitions: [def],
1402
1448
  collectAccount: true,
1403
1449
  });
1404
- const globallyUnique = index.find(def.name, { exact: true }).length === 1;
1450
+ const globallyUnique = index.symbols.get(def.name)?.length === 1;
1405
1451
  const sites = [
1406
1452
  ...raw.map(site => ({ ...site, _testEvidenceTier: 'confirmed' })),
1407
1453
  ...(globallyUnique ? (raw.unverifiedEntries || []).map(site => ({
@@ -1410,7 +1456,7 @@ function tests(index, nameOrFile, options = {}) {
1410
1456
  })) : []),
1411
1457
  ];
1412
1458
  for (const site of sites) {
1413
- const info = testFileInfo.get(site.file);
1459
+ const info = testInfoOf(site.file);
1414
1460
  if (!info) continue;
1415
1461
  if (info.testRanges && !lineInRanges(site.line, info.testRanges)) continue;
1416
1462
  let rows = fileTargetCallerSites.get(site.file);
@@ -1539,7 +1585,18 @@ function tests(index, nameOrFile, options = {}) {
1539
1585
  // own definition (fix #246).
1540
1586
  if (localShadow && !usage.receiver && usage.usageType !== 'import') continue;
1541
1587
 
1542
- if (pinnedFamily && usage.usageType === 'call') continue;
1588
+ // File eligibility only discovers candidates. A call's
1589
+ // spelling does not establish which declaration it reaches;
1590
+ // exact-target caller evidence below owns every call row.
1591
+ if (targetDefs.length > 0 && usage.usageType === 'call') continue;
1592
+ const classBodyProof = !usage.receiver && usage.usageType === 'reference' &&
1593
+ classBodyReferenceTargets(index, testPath, entry.language, usage, targetDefs);
1594
+ // Method/function values are caller candidates too. A file
1595
+ // importing any sibling cannot resurrect a reference the
1596
+ // engine attributed to another declaration.
1597
+ if (usage.usageType !== 'import' &&
1598
+ excludedCallableLines.get(testPath)?.get(usage.line) === targetDefs.length &&
1599
+ !acceptedCallableLines.has(`${testPath}:${usage.line}`) && !classBodyProof) continue;
1543
1600
  // A line is claimed only by a usage that is recorded: two
1544
1601
  // same-kind tokens on one line (`a.m = b.m`) are judged
1545
1602
  // separately, so a rejected first token never hides a
@@ -1625,6 +1682,7 @@ function tests(index, nameOrFile, options = {}) {
1625
1682
  const nmUsages = index._getCachedUsages(testPath, nm) || [];
1626
1683
  for (const u of nmUsages) {
1627
1684
  if (u.usageType === 'definition' || u.usageType === 'import') continue;
1685
+ if (u.usageType === 'call') continue; // exact file-target evidence below
1628
1686
  if (testRanges && !lineInRanges(u.line, testRanges)) continue;
1629
1687
  const lineKey = `${u.line}:${u.usageType}:${nm}`;
1630
1688
  if (seenLines.has(lineKey)) continue;
@@ -1648,9 +1706,9 @@ function tests(index, nameOrFile, options = {}) {
1648
1706
  const matchType = site._testEvidenceTier === 'confirmed'
1649
1707
  ? 'call'
1650
1708
  : 'unverified-call';
1651
- if (matches.some(m => m.line === site.line && m.matchType === matchType)) continue;
1709
+ if (matches.some(m => testSiteKey(m) === testSiteKey(site) && m.matchType === matchType)) continue;
1652
1710
  matches.push({
1653
- line: site.line,
1711
+ ...testSite(site),
1654
1712
  content: (site.content || index.getLineContent(testPath, site.line) || '').trim(),
1655
1713
  matchType,
1656
1714
  evidenceTier: site._testEvidenceTier,
@@ -1659,12 +1717,11 @@ function tests(index, nameOrFile, options = {}) {
1659
1717
  });
1660
1718
  }
1661
1719
 
1662
- // Language-aware test-case detection. Under a local same-name
1663
- // shadow the term on a test line is the file's OWN helper —
1664
- // only anchor test cases to matches that survived the shadow.
1720
+ // Under a local same-name shadow the term in a test's title is
1721
+ // the file's OWN helper: titles link only where a match survived.
1665
1722
  if (!isFilePath && sourceFileLinked &&
1666
1723
  (!localShadow || matches.some(m => m.matchType !== 'import'))) {
1667
- _addTestCaseMatches(index, testPath, entry, searchTerm, className, instanceTypeMap, matches);
1724
+ titled.add(entry.relativePath);
1668
1725
  }
1669
1726
 
1670
1727
  // Deduplicate: if a line already has a 'call' or 'import', don't also add 'test-case'
@@ -1694,38 +1751,20 @@ function tests(index, nameOrFile, options = {}) {
1694
1751
  }
1695
1752
  }
1696
1753
 
1697
- // Compiler-shaped caller supplement (trust command arm): the literal
1698
- // usage scan above is excellent for imports/type/string references, but
1699
- // receiver aliases and re-exported module calls are owned by the caller
1700
- // engine. Merge its CONFIRMED exact-target test sites so `tests` cannot
1701
- // miss calls that `about`/`impact` already know about. For a globally
1754
+ // Callable test links use the caller engine's exact-target decisions,
1755
+ // including aliases and function values that do not spell the target's
1756
+ // name. The literal scan contributes imports/type/string references,
1757
+ // never an independent call identity. For a globally
1702
1758
  // unique callable name, also retain account-unverified sites under the
1703
1759
  // explicit `unverified-call` label: independent compiler/LSP evaluation
1704
1760
  // shows these contain real interface/trait dispatch that a test-discovery
1705
1761
  // command must not silently drop. Repeated names never receive that broad
1706
1762
  // supplement because it would leak sibling classes/files.
1707
1763
  if (!isFilePath && targetDefs.length > 0) {
1708
- const testInfo = new Map(testFiles.map(t => [t.path, t]));
1709
- const callerSites = [];
1710
1764
  // A pinned definition's unverified sites were adjudicated against it
1711
1765
  // alone, like a globally unique name's (fix #430).
1712
- const globallyUniqueTarget = !!options.definition || index.find(searchTerm, { exact: true }).length === 1;
1713
- for (const def of targetDefs) {
1714
- const raw = index.findCallers(searchTerm, {
1715
- targetDefinitions: [def],
1716
- collectAccount: true,
1717
- });
1718
- callerSites.push(
1719
- ...raw.map(c => ({ ...c, _testEvidenceTier: 'confirmed' })),
1720
- ...(globallyUniqueTarget ? (raw.unverifiedEntries || []).map(c => ({
1721
- ...c,
1722
- _testEvidenceTier: 'unverified',
1723
- })) : []),
1724
- );
1725
- }
1726
-
1727
1766
  for (const site of callerSites) {
1728
- const info = testInfo.get(site.file);
1767
+ const info = testInfoOf(site.file);
1729
1768
  if (!info) continue;
1730
1769
  if (info.testRanges && !lineInRanges(site.line, info.testRanges)) continue;
1731
1770
  // Every site here was adjudicated against the exact target
@@ -1754,12 +1793,12 @@ function tests(index, nameOrFile, options = {}) {
1754
1793
  fileResult = { file: info.entry.relativePath, matches: [] };
1755
1794
  results.push(fileResult);
1756
1795
  }
1757
- if (fileResult.matches.some(m => m.line === site.line &&
1796
+ if (fileResult.matches.some(m => testSiteKey(m) === testSiteKey(site) &&
1758
1797
  ['call', 'unverified-call'].includes(m.matchType))) continue;
1759
1798
  fileResult.matches = fileResult.matches.filter(m =>
1760
1799
  !(m.line === site.line && m.matchType === 'test-case'));
1761
1800
  fileResult.matches.push({
1762
- line: site.line,
1801
+ ...testSite(site),
1763
1802
  content: (site.content || index.getLineContent(site.file, site.line) || '').trim(),
1764
1803
  matchType: site._testEvidenceTier === 'confirmed' ? 'call' : 'unverified-call',
1765
1804
  evidenceTier: site._testEvidenceTier,
@@ -1780,16 +1819,22 @@ function tests(index, nameOrFile, options = {}) {
1780
1819
  const nestedTypeDefs = targetDefs.filter(def => def.enclosingType &&
1781
1820
  ['class', 'struct', 'interface', 'type', 'enum', 'trait', 'record', 'union'].includes(def.type));
1782
1821
  if (accessorDefs.length > 0 || nestedTypeDefs.length > 0) {
1783
- const testPaths = new Set(testFiles.map(t => t.path));
1822
+ // Reference-only tests have no call site to add their file to
1823
+ // testFiles above. Let the reference engines find their sites
1824
+ // first, then ask the same model which lines a test executes.
1825
+ // The index-only gate spares files that cannot hold test code;
1826
+ // filenames alone never remove a candidate.
1827
+ const referencePaths = new Set([...index.files.keys()].filter(filePath =>
1828
+ testInfoByPath.get(filePath) || model.mayHoldTests(filePath)));
1784
1829
  const globallyUnique = index.find(searchTerm, { exact: true }).length === 1;
1785
1830
  for (const def of [...accessorDefs, ...nestedTypeDefs]) {
1786
1831
  const types = nestedTypeDefs.includes(def)
1787
- ? require('./analysis').findTypeReferences(index, searchTerm, def, { files: testPaths }) : null;
1832
+ ? require('./analysis').findTypeReferences(index, searchTerm, def, { files: referencePaths }) : null;
1788
1833
  const typeSite = site => ({ ...site, absoluteFile: path.join(index.root, site.file) });
1789
1834
  const refs = types ? {
1790
1835
  confirmed: types.byFile.flatMap(group => group.sites).map(typeSite),
1791
1836
  unverified: types.unverifiedSites.map(typeSite),
1792
- } : findAccessorReferences(index, searchTerm, def, { files: testPaths });
1837
+ } : findAccessorReferences(index, searchTerm, def, { files: referencePaths });
1793
1838
  if (!refs) continue;
1794
1839
  const rows = [
1795
1840
  ...refs.confirmed.map(r => ({ ...r, tierName: 'confirmed' })),
@@ -1797,7 +1842,7 @@ function tests(index, nameOrFile, options = {}) {
1797
1842
  ? refs.unverified.map(r => ({ ...r, tierName: 'unverified' })) : []),
1798
1843
  ];
1799
1844
  for (const ref of rows) {
1800
- const info = testInfo.get(ref.absoluteFile);
1845
+ const info = testInfoOf(ref.absoluteFile);
1801
1846
  if (!info) continue;
1802
1847
  if (info.testRanges && !lineInRanges(ref.line, info.testRanges)) continue;
1803
1848
  if (excludeArr.length > 0 &&
@@ -1835,6 +1880,55 @@ function tests(index, nameOrFile, options = {}) {
1835
1880
  results.sort((a, b) => codeUnitCompare(a.file || '', b.file || ''));
1836
1881
  }
1837
1882
 
1883
+ // The tests that hold the matches (core/test-units.js): one row per
1884
+ // test, then what the file is to its runner and how to run it.
1885
+ const absolute = new Map(testFiles.map(info => [info.entry.relativePath, info.path]));
1886
+ const linesByFile = new Map();
1887
+ for (const fileResult of results) {
1888
+ const testPath = absolute.get(fileResult.file);
1889
+ if (!testPath) continue;
1890
+ addTestCaseRows(model, index, testPath, isFilePath ? null : searchTerm, fileResult.matches, {
1891
+ counts: match => match.matchType !== 'import',
1892
+ titles: titled.has(fileResult.file),
1893
+ titleNeedsMatch: !!className,
1894
+ });
1895
+ linesByFile.set(testPath,
1896
+ fileResult.matches.filter(match => match.matchType !== 'import'));
1897
+ }
1898
+ // A test a class inherits runs where the class is: the file of a class
1899
+ // that inherits a linked test is a result of its own, with the test.
1900
+ const byFile = new Map(results.map(fileResult => [fileResult.file, fileResult]));
1901
+ for (const [testPath, linked] of linkFiles(model, index, linesByFile)) {
1902
+ const relativePath = index.files.get(testPath).relativePath;
1903
+ let fileResult = byFile.get(relativePath);
1904
+ if (!fileResult) {
1905
+ fileResult = { file: relativePath, matches: [] };
1906
+ byFile.set(relativePath, fileResult);
1907
+ results.push(fileResult);
1908
+ }
1909
+ for (const unit of linked.units) {
1910
+ if (!unit.inheritedFrom) continue;
1911
+ fileResult.matches.push({
1912
+ line: unit.line,
1913
+ content: (index.getLineContent(testPath, unit.line) || '').trim(),
1914
+ matchType: 'inherited-test',
1915
+ name: unit.name,
1916
+ from: unit.inheritedFrom,
1917
+ });
1918
+ }
1919
+ fileResult.matches.sort((a, b) => (a.line || 0) - (b.line || 0) ||
1920
+ codeUnitCompare(a.matchType || '', b.matchType || '') || codeUnitCompare(a.name || '', b.name || ''));
1921
+ fileResult.role = linked.role;
1922
+ if (linked.runner) fileResult.runner = linked.runner;
1923
+ fileResult.tests = linked.tests;
1924
+ if (linked.moreTests) fileResult.moreTests = true;
1925
+ if (linked.conditionalTests) fileResult.conditionalTests = linked.conditionalTests;
1926
+ if (linked.units.length > 0) fileResult.units = linked.units;
1927
+ if (linked.run) fileResult.run = linked.run;
1928
+ else if (linked.runWithheld) fileResult.runWithheld = linked.runWithheld;
1929
+ }
1930
+ results.sort((a, b) => codeUnitCompare(a.file || '', b.file || ''));
1931
+
1838
1932
  return results;
1839
1933
  } finally { index._endOp(); }
1840
1934
  }
@@ -2181,117 +2275,6 @@ function _receiverMatchesClass(usage, dispatchNames, instanceTypeMap, lineConten
2181
2275
  return false;
2182
2276
  }
2183
2277
 
2184
- /**
2185
- * Language-aware test-case detection.
2186
- *
2187
- * JS/TS: describe, it, test, spec calls with searchTerm in the description.
2188
- * Go: TestXxx, BenchmarkXxx, ExampleXxx functions containing a usage of searchTerm.
2189
- * Python: test_ functions or TestCase methods containing a usage of searchTerm.
2190
- * Java: @Test-annotated methods containing a usage of searchTerm.
2191
- * Rust: #[test]-attributed functions containing a usage of searchTerm.
2192
- */
2193
- function _addTestCaseMatches(index, filePath, fileEntry, searchTerm, className, instanceTypeMap, matches) {
2194
- const matchLines = new Set(matches.map(m => m.line));
2195
- const lang = fileEntry.language;
2196
-
2197
- if (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') {
2198
- // JS/TS: find describe/it/test/spec calls from getCachedCalls
2199
- const calls = getCachedCalls(index, filePath);
2200
- if (!calls) return;
2201
- const testFrameworkCalls = new Set(['describe', 'it', 'test', 'spec']);
2202
- // Word-boundary match: `describe("untargeted zone")` is not a test
2203
- // case for `target` (fix #246 — raw substring matched mid-word).
2204
- const termPattern = new RegExp('(^|[^\\w$])' + escapeRegExp(searchTerm) + '([^\\w$]|$)');
2205
- for (const call of calls) {
2206
- if (!testFrameworkCalls.has(call.name)) continue;
2207
- const lineContent = index.getLineContent(filePath, call.line);
2208
- // Check if searchTerm appears in the description string on this line
2209
- if (termPattern.test(lineContent) && !matchLines.has(call.line)) {
2210
- // className scoping: only add test-case if the test body has a
2211
- // class-scoped match (call or class-receiver reference) — not just
2212
- // any mention of className.
2213
- if (className) {
2214
- const endLine = _estimateTestBlockEnd(index, filePath, call.line);
2215
- const hasClassScopedMatch = matches.some(m =>
2216
- m.line >= call.line && m.line <= endLine &&
2217
- m.matchType !== 'import'
2218
- );
2219
- if (!hasClassScopedMatch) continue;
2220
- }
2221
- matches.push({
2222
- line: call.line,
2223
- content: lineContent.trim(),
2224
- matchType: 'test-case'
2225
- });
2226
- matchLines.add(call.line);
2227
- }
2228
- }
2229
- } else {
2230
- // Go/Python/Java/Rust: check if any AST usage falls within a test function's range
2231
- if (!fileEntry.symbols) return;
2232
- try {
2233
- const langModule = getLanguageAdapter(lang);
2234
- if (!langModule) return;
2235
- // Prefer kinded predicate so fn main() and fn init() aren't classified
2236
- // as test cases (BUG-CX). Fall back to isEntryPoint for compat.
2237
- const classify = langModule.getEntryPointKind
2238
- ? (s) => langModule.getEntryPointKind(s) === 'test'
2239
- : langModule.isEntryPoint;
2240
- if (!classify) return;
2241
-
2242
- // Find test symbols
2243
- for (const symbol of fileEntry.symbols) {
2244
- if (!classify(symbol)) continue;
2245
- // Check if any non-import usage of searchTerm falls within this test function
2246
- const usageInRange = matches.some(m =>
2247
- m.line >= symbol.startLine && m.line <= symbol.endLine &&
2248
- m.matchType !== 'import'
2249
- );
2250
- if (usageInRange && !matchLines.has(symbol.startLine)) {
2251
- // className scoping: verify the test body has class-scoped matches
2252
- if (className) {
2253
- const hasClassScopedMatch = matches.some(m =>
2254
- m.line >= symbol.startLine && m.line <= symbol.endLine &&
2255
- m.matchType !== 'import'
2256
- );
2257
- if (!hasClassScopedMatch) continue;
2258
- }
2259
- const lineContent = index.getLineContent(filePath, symbol.startLine);
2260
- matches.push({
2261
- line: symbol.startLine,
2262
- content: lineContent.trim(),
2263
- matchType: 'test-case'
2264
- });
2265
- matchLines.add(symbol.startLine);
2266
- }
2267
- }
2268
- } catch (e) {
2269
- // Skip if language module unavailable
2270
- }
2271
- }
2272
- }
2273
-
2274
- /**
2275
- * Estimate the end line of a test block (it/test/describe callback) by tracking
2276
- * brace nesting from the start line.
2277
- */
2278
- function _estimateTestBlockEnd(index, filePath, startLine) {
2279
- const content = index._readFile(filePath);
2280
- if (!content) return startLine + 5;
2281
- const lines = content.split('\n');
2282
- let depth = 0;
2283
- let started = false;
2284
- for (let i = startLine - 1; i < lines.length; i++) {
2285
- const line = lines[i];
2286
- for (const ch of line) {
2287
- if (ch === '{' || ch === '(') { depth++; started = true; }
2288
- else if (ch === '}' || ch === ')') { depth--; }
2289
- }
2290
- if (started && depth <= 0) return i + 1; // 1-based
2291
- }
2292
- return Math.min(startLine + 10, lines.length);
2293
- }
2294
-
2295
2278
  /**
2296
2279
  * Deduplicate matches: prefer more specific matchTypes on the same line.
2297
2280
  * Priority: test-case > call > import > string-ref > reference
@@ -2300,9 +2283,10 @@ function _deduplicateMatches(matches) {
2300
2283
  const byLine = new Map();
2301
2284
  const priority = { 'test-case': 5, 'call': 4, 'import': 3, 'string-ref': 2, 'reference': 1 };
2302
2285
  for (const m of matches) {
2303
- const existing = byLine.get(m.line);
2286
+ const key = testSiteKey(m);
2287
+ const existing = byLine.get(key);
2304
2288
  if (!existing || (priority[m.matchType] || 0) > (priority[existing.matchType] || 0)) {
2305
- byLine.set(m.line, m);
2289
+ byLine.set(key, m);
2306
2290
  }
2307
2291
  }
2308
2292
  return [...byLine.values()].sort((a, b) => a.line - b.line);
package/core/shared.js CHANGED
@@ -260,49 +260,6 @@ function isOverrideMarked(def) {
260
260
  return false;
261
261
  }
262
262
 
263
- // Per-language text patterns for the "blind spots" UCN's AST can't follow:
264
- // eval/exec-style code execution and reflection (dynamic attribute access /
265
- // dynamic dispatch). ONE source of truth so doctor's trust scan and
266
- // detectCompleteness's about-footer warning count identically (field-report #2:
267
- // they used to diverge — doctor 497 reflection vs footer 194, eval 3 vs 2 —
268
- // because each kept its own regex set). Dynamic imports are NOT here: those are
269
- // structural (fileEntry.dynamicImports), the AST-accurate count both paths share.
270
- // `new Function(...)` is categorized as eval (code execution), not reflection.
271
- const BLINDSPOT_TEXT_PATTERNS = {
272
- reflection: {
273
- python: /\b(getattr|hasattr|setattr|__import__|importlib\.import_module)\s*\(/g,
274
- javascript: /\bReflect\.\w+\s*\(/g,
275
- typescript: /\bReflect\.\w+\s*\(/g,
276
- go: /\breflect\.\w+\s*\(/g,
277
- java: /\.getDeclaredMethod\b|\.getMethod\b|\.getDeclaredField\b|Class\.forName\b/g,
278
- rust: /\bAny::downcast/g,
279
- },
280
- eval: {
281
- python: /\b(eval|exec)\s*\(/g,
282
- javascript: /\beval\s*\(|\bnew\s+Function\s*\(/g,
283
- typescript: /\beval\s*\(|\bnew\s+Function\s*\(/g,
284
- },
285
- };
286
-
287
- /** True when a language has any text-blind-spot pattern (so callers can skip the file read otherwise). */
288
- function hasTextBlindspots(language) {
289
- return !!(BLINDSPOT_TEXT_PATTERNS.reflection[language] || BLINDSPOT_TEXT_PATTERNS.eval[language]);
290
- }
291
-
292
- /**
293
- * Count text-detected blind spots (eval/exec, reflection) in one file's source.
294
- * Returns { eval, reflection } OCCURRENCE counts (global match). Shared by doctor
295
- * and detectCompleteness so both report the same numbers (field-report #2).
296
- */
297
- function countTextBlindspots(content, language) {
298
- const reRe = BLINDSPOT_TEXT_PATTERNS.reflection[language];
299
- const evRe = BLINDSPOT_TEXT_PATTERNS.eval[language];
300
- return {
301
- eval: evRe ? (content.match(evRe) || []).length : 0,
302
- reflection: reRe ? (content.match(reRe) || []).length : 0,
303
- };
304
- }
305
-
306
263
  /**
307
264
  * Line ranges of INLINE test symbols in a source file — Rust #[test] fns and
308
265
  * #[cfg(test)] module members. A production file promoted to "test file"
@@ -614,8 +571,6 @@ module.exports = { literalNameRegex,
614
571
  looksLikeHandle,
615
572
  isTestPath,
616
573
  isOverrideMarked,
617
- hasTextBlindspots,
618
- countTextBlindspots,
619
574
  isPathInsideRoot,
620
575
  isMacroNamespaceDefinition,
621
576
  };