ucn 5.5.2 → 5.6.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 (74) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -12
  2. package/.claude/skills/ucn/references/commands.md +21 -17
  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 +474 -0
  6. package/README.md +71 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +138 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +29 -65
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/client-receivers.js +189 -0
  19. package/core/command-contracts.js +7 -6
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +388 -85
  23. package/core/deletion.js +401 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoints.js +12 -6
  26. package/core/evidence-bands.js +68 -0
  27. package/core/evidence-bands.json +44 -0
  28. package/core/execute.js +172 -161
  29. package/core/hot-summary.js +241 -0
  30. package/core/index-ir.js +3 -1
  31. package/core/inprocess-clients.js +41 -15
  32. package/core/ir.js +5 -1
  33. package/core/member-signatures.js +789 -0
  34. package/core/output/analysis-ext.js +20 -10
  35. package/core/output/analysis.js +98 -60
  36. package/core/output/check.js +98 -37
  37. package/core/output/doctor.js +34 -1
  38. package/core/output/find.js +2 -1
  39. package/core/output/lines.js +63 -3
  40. package/core/output/public.js +30 -11
  41. package/core/output/refactoring.js +32 -0
  42. package/core/output/reporting.js +21 -2
  43. package/core/output/search.js +11 -2
  44. package/core/output/tracing.js +35 -7
  45. package/core/output-budget.js +124 -57
  46. package/core/parallel-build.js +3 -3
  47. package/core/project.js +106 -23
  48. package/core/provenance-type.js +12 -5
  49. package/core/public-command.js +35 -9
  50. package/core/python-modules.js +6 -0
  51. package/core/reflection.js +166 -23
  52. package/core/registry.js +623 -72
  53. package/core/reporting.js +69 -34
  54. package/core/request-helpers.js +63 -7
  55. package/core/review-items.js +75 -0
  56. package/core/search.js +89 -128
  57. package/core/shared.js +0 -45
  58. package/core/snapshot.js +350 -0
  59. package/core/source-universe.js +707 -0
  60. package/core/test-units.js +3624 -0
  61. package/core/tracing.js +150 -96
  62. package/core/verify.js +27 -11
  63. package/languages/adapter.js +8 -3
  64. package/languages/c-family.js +12 -0
  65. package/languages/csharp.js +634 -0
  66. package/languages/go.js +120 -0
  67. package/languages/index.js +422 -4
  68. package/languages/java.js +712 -0
  69. package/languages/javascript.js +1460 -2
  70. package/languages/lexical-scope.js +68 -0
  71. package/languages/python.js +1672 -1
  72. package/languages/rust.js +91 -0
  73. package/mcp/server.js +44 -110
  74. package/package.json +11 -6
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 } = 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.
@@ -1313,6 +1312,10 @@ function tests(index, nameOrFile, options = {}) {
1313
1312
  index._beginOp();
1314
1313
  try {
1315
1314
  const results = [];
1315
+ const model = createTestModel(index);
1316
+ // Files where a title that names the term is a link (the term on a test
1317
+ // line is the project's symbol, not the file's own helper).
1318
+ const titled = new Set();
1316
1319
 
1317
1320
  // Check if it's a file path
1318
1321
  const isFilePath = nameOrFile.includes('/') || nameOrFile.includes('\\') ||
@@ -1358,6 +1361,23 @@ function tests(index, nameOrFile, options = {}) {
1358
1361
  }
1359
1362
  }
1360
1363
 
1364
+ // A file's name does not decide what a test executes: the body of a
1365
+ // test a class inherits stands where its base class is, and a runner
1366
+ // takes tests from files of any name. A call site the caller engine
1367
+ // finds in such a file is a test's where the test model says a test
1368
+ // executes that line (core/test-units.js): the file joins the test
1369
+ // files, test code within those lines.
1370
+ const testInfoByPath = new Map(testFiles.map(info => [info.path, info]));
1371
+ const testInfoOf = filePath => {
1372
+ if (testInfoByPath.has(filePath)) return testInfoByPath.get(filePath);
1373
+ const entry = index.files.get(filePath);
1374
+ const ranges = entry ? model.testRangesOf(filePath) : [];
1375
+ const info = ranges.length > 0 ? { path: filePath, entry, testRanges: ranges } : null;
1376
+ testInfoByPath.set(filePath, info);
1377
+ if (info) testFiles.push(info);
1378
+ return info;
1379
+ };
1380
+
1361
1381
  const searchTerm = isFilePath
1362
1382
  ? path.basename(nameOrFile, path.extname(nameOrFile))
1363
1383
  : nameOrFile;
@@ -1388,7 +1408,6 @@ function tests(index, nameOrFile, options = {}) {
1388
1408
  // module-level coverage remain visible.
1389
1409
  const fileTargetCallerSites = new Map(); // absolute test file → tiered sites
1390
1410
  if (isFilePath && targetRels) {
1391
- const testFileInfo = new Map(testFiles.map(info => [info.path, info]));
1392
1411
  const fileTargetDefs = [];
1393
1412
  for (const [, fe] of index.files) {
1394
1413
  if (!targetRels.has(fe.relativePath)) continue;
@@ -1410,7 +1429,7 @@ function tests(index, nameOrFile, options = {}) {
1410
1429
  })) : []),
1411
1430
  ];
1412
1431
  for (const site of sites) {
1413
- const info = testFileInfo.get(site.file);
1432
+ const info = testInfoOf(site.file);
1414
1433
  if (!info) continue;
1415
1434
  if (info.testRanges && !lineInRanges(site.line, info.testRanges)) continue;
1416
1435
  let rows = fileTargetCallerSites.get(site.file);
@@ -1659,12 +1678,11 @@ function tests(index, nameOrFile, options = {}) {
1659
1678
  });
1660
1679
  }
1661
1680
 
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.
1681
+ // Under a local same-name shadow the term in a test's title is
1682
+ // the file's OWN helper: titles link only where a match survived.
1665
1683
  if (!isFilePath && sourceFileLinked &&
1666
1684
  (!localShadow || matches.some(m => m.matchType !== 'import'))) {
1667
- _addTestCaseMatches(index, testPath, entry, searchTerm, className, instanceTypeMap, matches);
1685
+ titled.add(entry.relativePath);
1668
1686
  }
1669
1687
 
1670
1688
  // Deduplicate: if a line already has a 'call' or 'import', don't also add 'test-case'
@@ -1705,7 +1723,6 @@ function tests(index, nameOrFile, options = {}) {
1705
1723
  // command must not silently drop. Repeated names never receive that broad
1706
1724
  // supplement because it would leak sibling classes/files.
1707
1725
  if (!isFilePath && targetDefs.length > 0) {
1708
- const testInfo = new Map(testFiles.map(t => [t.path, t]));
1709
1726
  const callerSites = [];
1710
1727
  // A pinned definition's unverified sites were adjudicated against it
1711
1728
  // alone, like a globally unique name's (fix #430).
@@ -1725,7 +1742,7 @@ function tests(index, nameOrFile, options = {}) {
1725
1742
  }
1726
1743
 
1727
1744
  for (const site of callerSites) {
1728
- const info = testInfo.get(site.file);
1745
+ const info = testInfoOf(site.file);
1729
1746
  if (!info) continue;
1730
1747
  if (info.testRanges && !lineInRanges(site.line, info.testRanges)) continue;
1731
1748
  // Every site here was adjudicated against the exact target
@@ -1780,16 +1797,22 @@ function tests(index, nameOrFile, options = {}) {
1780
1797
  const nestedTypeDefs = targetDefs.filter(def => def.enclosingType &&
1781
1798
  ['class', 'struct', 'interface', 'type', 'enum', 'trait', 'record', 'union'].includes(def.type));
1782
1799
  if (accessorDefs.length > 0 || nestedTypeDefs.length > 0) {
1783
- const testPaths = new Set(testFiles.map(t => t.path));
1800
+ // Reference-only tests have no call site to add their file to
1801
+ // testFiles above. Let the reference engines find their sites
1802
+ // first, then ask the same model which lines a test executes.
1803
+ // The index-only gate spares files that cannot hold test code;
1804
+ // filenames alone never remove a candidate.
1805
+ const referencePaths = new Set([...index.files.keys()].filter(filePath =>
1806
+ testInfoByPath.get(filePath) || model.mayHoldTests(filePath)));
1784
1807
  const globallyUnique = index.find(searchTerm, { exact: true }).length === 1;
1785
1808
  for (const def of [...accessorDefs, ...nestedTypeDefs]) {
1786
1809
  const types = nestedTypeDefs.includes(def)
1787
- ? require('./analysis').findTypeReferences(index, searchTerm, def, { files: testPaths }) : null;
1810
+ ? require('./analysis').findTypeReferences(index, searchTerm, def, { files: referencePaths }) : null;
1788
1811
  const typeSite = site => ({ ...site, absoluteFile: path.join(index.root, site.file) });
1789
1812
  const refs = types ? {
1790
1813
  confirmed: types.byFile.flatMap(group => group.sites).map(typeSite),
1791
1814
  unverified: types.unverifiedSites.map(typeSite),
1792
- } : findAccessorReferences(index, searchTerm, def, { files: testPaths });
1815
+ } : findAccessorReferences(index, searchTerm, def, { files: referencePaths });
1793
1816
  if (!refs) continue;
1794
1817
  const rows = [
1795
1818
  ...refs.confirmed.map(r => ({ ...r, tierName: 'confirmed' })),
@@ -1797,7 +1820,7 @@ function tests(index, nameOrFile, options = {}) {
1797
1820
  ? refs.unverified.map(r => ({ ...r, tierName: 'unverified' })) : []),
1798
1821
  ];
1799
1822
  for (const ref of rows) {
1800
- const info = testInfo.get(ref.absoluteFile);
1823
+ const info = testInfoOf(ref.absoluteFile);
1801
1824
  if (!info) continue;
1802
1825
  if (info.testRanges && !lineInRanges(ref.line, info.testRanges)) continue;
1803
1826
  if (excludeArr.length > 0 &&
@@ -1835,6 +1858,55 @@ function tests(index, nameOrFile, options = {}) {
1835
1858
  results.sort((a, b) => codeUnitCompare(a.file || '', b.file || ''));
1836
1859
  }
1837
1860
 
1861
+ // The tests that hold the matches (core/test-units.js): one row per
1862
+ // test, then what the file is to its runner and how to run it.
1863
+ const absolute = new Map(testFiles.map(info => [info.entry.relativePath, info.path]));
1864
+ const linesByFile = new Map();
1865
+ for (const fileResult of results) {
1866
+ const testPath = absolute.get(fileResult.file);
1867
+ if (!testPath) continue;
1868
+ addTestCaseRows(model, index, testPath, isFilePath ? null : searchTerm, fileResult.matches, {
1869
+ counts: match => match.matchType !== 'import',
1870
+ titles: titled.has(fileResult.file),
1871
+ titleNeedsMatch: !!className,
1872
+ });
1873
+ linesByFile.set(testPath,
1874
+ fileResult.matches.filter(match => match.matchType !== 'import').map(match => match.line));
1875
+ }
1876
+ // A test a class inherits runs where the class is: the file of a class
1877
+ // that inherits a linked test is a result of its own, with the test.
1878
+ const byFile = new Map(results.map(fileResult => [fileResult.file, fileResult]));
1879
+ for (const [testPath, linked] of linkFiles(model, index, linesByFile)) {
1880
+ const relativePath = index.files.get(testPath).relativePath;
1881
+ let fileResult = byFile.get(relativePath);
1882
+ if (!fileResult) {
1883
+ fileResult = { file: relativePath, matches: [] };
1884
+ byFile.set(relativePath, fileResult);
1885
+ results.push(fileResult);
1886
+ }
1887
+ for (const unit of linked.units) {
1888
+ if (!unit.inheritedFrom) continue;
1889
+ fileResult.matches.push({
1890
+ line: unit.line,
1891
+ content: (index.getLineContent(testPath, unit.line) || '').trim(),
1892
+ matchType: 'inherited-test',
1893
+ name: unit.name,
1894
+ from: unit.inheritedFrom,
1895
+ });
1896
+ }
1897
+ fileResult.matches.sort((a, b) => (a.line || 0) - (b.line || 0) ||
1898
+ codeUnitCompare(a.matchType || '', b.matchType || '') || codeUnitCompare(a.name || '', b.name || ''));
1899
+ fileResult.role = linked.role;
1900
+ if (linked.runner) fileResult.runner = linked.runner;
1901
+ fileResult.tests = linked.tests;
1902
+ if (linked.moreTests) fileResult.moreTests = true;
1903
+ if (linked.conditionalTests) fileResult.conditionalTests = linked.conditionalTests;
1904
+ if (linked.units.length > 0) fileResult.units = linked.units;
1905
+ if (linked.run) fileResult.run = linked.run;
1906
+ else if (linked.runWithheld) fileResult.runWithheld = linked.runWithheld;
1907
+ }
1908
+ results.sort((a, b) => codeUnitCompare(a.file || '', b.file || ''));
1909
+
1838
1910
  return results;
1839
1911
  } finally { index._endOp(); }
1840
1912
  }
@@ -2181,117 +2253,6 @@ function _receiverMatchesClass(usage, dispatchNames, instanceTypeMap, lineConten
2181
2253
  return false;
2182
2254
  }
2183
2255
 
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
2256
  /**
2296
2257
  * Deduplicate matches: prefer more specific matchTypes on the same line.
2297
2258
  * Priority: test-case > call > import > string-ref > reference
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
  };