ucn 5.5.3 → 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 (71) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -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 +474 -0
  6. package/README.md +69 -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 +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  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/command-contracts.js +7 -6
  19. package/core/constructed-class.js +1195 -0
  20. package/core/contract-membership.js +142 -0
  21. package/core/deadcode.js +362 -84
  22. package/core/deletion.js +401 -0
  23. package/core/discovery.js +104 -9
  24. package/core/entrypoints.js +12 -6
  25. package/core/evidence-bands.js +68 -0
  26. package/core/evidence-bands.json +44 -0
  27. package/core/execute.js +172 -161
  28. package/core/hot-summary.js +241 -0
  29. package/core/index-ir.js +3 -1
  30. package/core/ir.js +5 -1
  31. package/core/member-signatures.js +789 -0
  32. package/core/output/analysis-ext.js +20 -10
  33. package/core/output/analysis.js +98 -60
  34. package/core/output/check.js +98 -37
  35. package/core/output/doctor.js +34 -1
  36. package/core/output/find.js +2 -1
  37. package/core/output/lines.js +63 -3
  38. package/core/output/public.js +29 -11
  39. package/core/output/refactoring.js +32 -0
  40. package/core/output/reporting.js +15 -2
  41. package/core/output/search.js +11 -2
  42. package/core/output/tracing.js +35 -7
  43. package/core/output-budget.js +124 -57
  44. package/core/parallel-build.js +3 -3
  45. package/core/project.js +106 -23
  46. package/core/provenance-type.js +12 -5
  47. package/core/public-command.js +35 -9
  48. package/core/python-modules.js +6 -0
  49. package/core/reflection.js +151 -23
  50. package/core/registry.js +623 -72
  51. package/core/reporting.js +69 -34
  52. package/core/review-items.js +75 -0
  53. package/core/search.js +89 -128
  54. package/core/shared.js +0 -45
  55. package/core/snapshot.js +350 -0
  56. package/core/source-universe.js +707 -0
  57. package/core/test-units.js +3624 -0
  58. package/core/tracing.js +150 -96
  59. package/core/verify.js +27 -11
  60. package/languages/adapter.js +8 -3
  61. package/languages/c-family.js +12 -0
  62. package/languages/csharp.js +634 -0
  63. package/languages/go.js +120 -0
  64. package/languages/index.js +422 -4
  65. package/languages/java.js +712 -0
  66. package/languages/javascript.js +1460 -2
  67. package/languages/lexical-scope.js +68 -0
  68. package/languages/python.js +1672 -1
  69. package/languages/rust.js +91 -0
  70. package/mcp/server.js +44 -110
  71. package/package.json +10 -5
package/core/bridge.js CHANGED
@@ -522,6 +522,19 @@ const GO_ROUTER_MODULES = [
522
522
  [/^github\.com\/julienschmidt\/httprouter$/, 'httprouter'],
523
523
  ];
524
524
 
525
+ // The framework labels a route or a request can carry. A label is known to
526
+ // the `framework` filter when it is listed here or appears in the answer it
527
+ // filters, so a label the answer prints is always one the filter takes.
528
+ const ENDPOINT_FRAMEWORKS = Object.freeze([...new Set([
529
+ ...Object.values(SERVER_RECEIVER_PATTERNS).flat().map(pattern => pattern.framework),
530
+ ...Object.values(CLIENT_PATTERNS).flatMap(patterns => patterns.receivers).map(pattern => pattern.framework),
531
+ ...JS_ROUTER_MODULES.map(([, framework]) => framework),
532
+ ...GO_ROUTER_MODULES.map(([, framework]) => framework),
533
+ 'actix', 'aiohttp', 'aspnet', 'django', 'fastapi', 'fetch', 'flask', 'go-inprocess',
534
+ 'inject', 'jax-rs', 'nestjs', 'nextjs', 'request-helper', 'route-config', 'spring',
535
+ 'starlette', 'supertest', 'unknown-python',
536
+ ])].sort());
537
+
525
538
  function frameworkOfModule(table, spec) {
526
539
  for (const [re, framework] of table) if (re.test(spec)) return framework;
527
540
  return null;
@@ -3138,6 +3151,7 @@ function endpoints(index, options = {}) {
3138
3151
  }
3139
3152
 
3140
3153
  module.exports = {
3154
+ ENDPOINT_FRAMEWORKS,
3141
3155
  endpoints,
3142
3156
  extractServerRoutes,
3143
3157
  extractClientRequests,
package/core/cache.js CHANGED
@@ -10,8 +10,8 @@ const path = require('path');
10
10
  const os = require('os');
11
11
  const crypto = require('crypto');
12
12
  const {
13
- expandGlob, detectProjectPattern, parseGitignore, gitTrackedPaths, gitListing, DEFAULT_IGNORES,
14
- classifyUnsupportedSourceFile,
13
+ expandGlob, detectProjectPattern, parseGitignore, gitTrackedPaths, gitListing,
14
+ classifyUnsupportedSourceFile, projectDiscoveryOptions,
15
15
  } = require('./discovery');
16
16
  const { codeUnitCompare } = require('./shared');
17
17
  const { syntaxWeightKind } = require('./source-memo');
@@ -998,7 +998,24 @@ function clearAllCaches() {
998
998
  // v326: C++ files retain raw base-clause positions and their syntactic scopes.
999
999
  // v327: Python explicit-argument super calls retain a producer, not a lexical parent receiver.
1000
1000
  // v328: ambiguous C++ direct initializers retain empty template-call records.
1001
- const CACHE_FORMAT_VERSION = 330;
1001
+ // v331: source paths excluded by .ucn.json are recorded one issue per source
1002
+ // file (searched as text per name), with `outside-project` and
1003
+ // `text-scan-budget` reasons; parse-failed files keep their size and mtime.
1004
+ // v332: members a subtype or a structural satisfier must supply carry
1005
+ // `requiredMember` (interface and object-type signatures that are not
1006
+ // optional, abstract members, bodiless trait methods, Go interface method
1007
+ // elements, Python abstract and Protocol members, C++ pure virtuals); a
1008
+ // Python protocol class carries `protocolClass`.
1009
+ // v333: a JS/TS file's `moduleAssignedNames` leaves out writes to a name a
1010
+ // function or block declares itself (they never reach the module's binding);
1011
+ // a Python call whose only argument is a bare generator counts one argument.
1012
+ // v334: file entries carry `reflectionUses` and `evalUses`, call sites of the
1013
+ // language's reflection and code-execution vocabulary counted in the tree at
1014
+ // index time (`repo` and the completeness footer counted them by reading
1015
+ // every file's text on each query).
1016
+ // v335: reflection/eval facts follow commented and parenthesized callees;
1017
+ // package reflection qualifiers retain Unicode names and lexical ownership.
1018
+ const CACHE_FORMAT_VERSION = 335;
1002
1019
  const USAGE_CACHE_FILE = 'usage-results.json';
1003
1020
 
1004
1021
  function usageResultKey(index, filePath, name, mode) {
@@ -1141,6 +1158,12 @@ function saveCache(index, cachePath) {
1141
1158
 
1142
1159
  const cacheFile = cachePath || path.join(cacheDir, 'index.json');
1143
1160
 
1161
+ // HOT rankings stored for the generation in memory describe the state
1162
+ // this save writes: read them before the generation is replaced, and
1163
+ // store them again under the new one below.
1164
+ const hotSummary = require('./hot-summary');
1165
+ hotSummary.ensure(index);
1166
+
1144
1167
  // A partially loaded (per-file) calls cache must be complete before it
1145
1168
  // is rewritten: the shard directory is replaced wholesale below.
1146
1169
  if (index._callsCachePrepared && !index._callsCacheLoaded) {
@@ -1270,6 +1293,9 @@ function saveCache(index, cachePath) {
1270
1293
  failedFiles: index.failedFiles
1271
1294
  ? Array.from(index.failedFiles).map(f => path.relative(root, f))
1272
1295
  : [],
1296
+ // A parse failure is retried when its file changes: keep what the
1297
+ // file looked like when it failed.
1298
+ failedFileStats: failedFileStats(index, root),
1273
1299
  unsupportedFiles: Array.isArray(index.unsupportedFiles)
1274
1300
  ? index.unsupportedFiles
1275
1301
  : [],
@@ -1314,6 +1340,7 @@ function saveCache(index, cachePath) {
1314
1340
  const tmpFile = cacheFile + '.tmp';
1315
1341
  fs.writeFileSync(tmpFile, JSON.stringify(cacheData));
1316
1342
  fs.renameSync(tmpFile, cacheFile);
1343
+ index._cacheDir = path.dirname(cacheFile);
1317
1344
 
1318
1345
  // MED-1 (Round 5): clear the reachabilityDirty flag now that the set is
1319
1346
  // safely persisted. The cli/index.js cache-save guard checks this flag
@@ -1384,6 +1411,11 @@ function saveCache(index, cachePath) {
1384
1411
  }
1385
1412
  }
1386
1413
 
1414
+ // A failed save must not authorize a summary for a state that was never
1415
+ // completely written. In particular, direct indexFile mutations cannot
1416
+ // publish against the old calls generation while only in memory.
1417
+ hotSummary.adoptSavedGeneration(index);
1418
+ try { hotSummary.save(index, cacheFile); } catch (_) { /* the ranking is computed again */ }
1387
1419
  if (!cachePath) pruneUserCache();
1388
1420
  return cacheFile;
1389
1421
  }
@@ -1453,6 +1485,7 @@ function loadCache(index, cachePath) {
1453
1485
  index._clearParsedTreeCache?.();
1454
1486
  index._groundSetCache = new Map();
1455
1487
  index._groundSetCacheLines = 0;
1488
+ index._sourceUniverse = null;
1456
1489
  index._nameBindingReachCache = new Map();
1457
1490
  index._returnTypeFlowCache = new Map();
1458
1491
  index._classIdentity = null;
@@ -1533,6 +1566,9 @@ function loadCache(index, cachePath) {
1533
1566
  ? cacheData.callsGeneration : null;
1534
1567
  index._callNameCache = null;
1535
1568
  index._callNameCacheReadGeneration = null;
1569
+ // HOT rankings stored beside this file are read when one is asked.
1570
+ index._cacheDir = path.dirname(cacheFile);
1571
+ require('./hot-summary').forget(index);
1536
1572
 
1537
1573
  // Prepare lazy calls cache loading — load manifest but defer shard parsing.
1538
1574
  // Shards are loaded on first getCachedCalls access via ensureCallsCacheLoaded().
@@ -1551,6 +1587,8 @@ function loadCache(index, cachePath) {
1551
1587
  cacheData.failedFiles.map(f => path.isAbsolute(f) ? f : toAbs(f))
1552
1588
  );
1553
1589
  }
1590
+ index._failedFileStats = new Map(Object.entries(cacheData.failedFileStats || {})
1591
+ .map(([relative, stat]) => [toAbs(relative), { size: stat[0], mtimeMs: stat[1] }]));
1554
1592
  index.unsupportedFiles = Array.isArray(cacheData.unsupportedFiles)
1555
1593
  ? cacheData.unsupportedFiles
1556
1594
  : [];
@@ -1651,6 +1689,17 @@ function loadCache(index, cachePath) {
1651
1689
  * @param {object} index - ProjectIndex instance
1652
1690
  * @returns {boolean} - True if cache needs rebuilding
1653
1691
  */
1692
+ function failedFileStats(index, root) {
1693
+ const stats = {};
1694
+ // Only what the build recorded at the failure: a stat taken now could
1695
+ // describe a file that was repaired since.
1696
+ for (const failedPath of index.failedFiles || []) {
1697
+ const known = index._failedFileStats && index._failedFileStats.get(failedPath);
1698
+ if (known) stats[path.relative(root, failedPath)] = [known.size, known.mtimeMs];
1699
+ }
1700
+ return stats;
1701
+ }
1702
+
1654
1703
  function isCacheStale(index) {
1655
1704
  const currentConfigHash = crypto.createHash('md5')
1656
1705
  .update(JSON.stringify(index.config || {})).digest('hex');
@@ -1706,6 +1755,38 @@ function isCacheStale(index) {
1706
1755
  }
1707
1756
  }
1708
1757
 
1758
+ // A file that failed to parse is a source file: when it changes, a
1759
+ // build must try it again (it is in no other sweep, so a repaired file
1760
+ // used to stay "failed" until something else forced a rebuild).
1761
+ if (index.failedFiles && index.failedFiles.size > 0) {
1762
+ for (const failedPath of index.failedFiles) {
1763
+ if (index.files.has(failedPath)) continue;
1764
+ let stat;
1765
+ try { stat = fs.statSync(failedPath); } catch (_) { return true; }
1766
+ // The build records what the file looked like when it failed
1767
+ // (ProjectIndex._noteFailedFile). Without that record nothing
1768
+ // shows the file is unchanged, so it is tried again: reading the
1769
+ // stat here instead would call a repaired file "unchanged".
1770
+ const known = index._failedFileStats && index._failedFileStats.get(failedPath);
1771
+ if (!known || known.size !== stat.size || known.mtimeMs !== stat.mtimeMs) return true;
1772
+ }
1773
+ }
1774
+
1775
+ // Text-only source (core/source-universe.js) is searched per name, never
1776
+ // indexed. An edit that leaves a file text-only changes no symbol: the
1777
+ // classification is refreshed and name scans are dropped, with no
1778
+ // rebuild. An edit that can change WHAT is indexed (a file gone, an
1779
+ // oversized file back under the limit) is a rebuild.
1780
+ const discovery = projectDiscoveryOptions(index.root, index.config || {},
1781
+ { includeBundled: index.includeBundled === true });
1782
+ const refreshed = require('./source-universe').refreshTextOnly(index,
1783
+ { maxFileSize: discovery.maxFileSize ?? (8 * 1024 * 1024) });
1784
+ if (refreshed === 'reindex') return true;
1785
+ if (refreshed === 'refreshed') {
1786
+ index._groundSetCache = new Map();
1787
+ index._groundSetCacheLines = 0;
1788
+ }
1789
+
1709
1790
  // Cargo manifests steer Rust import resolution (fix #372).
1710
1791
  if (index.cargoManifests &&
1711
1792
  !require('./imports').workspaceManifestsCurrent(index.root, index.cargoManifests)) {
@@ -1723,13 +1804,14 @@ function isCacheStale(index) {
1723
1804
  // Only reached when all cached files are unchanged.
1724
1805
  const pattern = detectProjectPattern(index.root);
1725
1806
  const currentUnsupported = [];
1726
- const currentBundled = [];
1807
+ const currentIssues = [];
1727
1808
  const currentManifests = [];
1809
+ // The same options the build used (shared with project.js): a walk that
1810
+ // reads the configuration differently sees a different file set.
1728
1811
  const globOpts = {
1729
- root: index.root,
1730
- includeBundled: index.includeBundled === true,
1812
+ ...discovery,
1731
1813
  onDiscoveryIssue: issue => {
1732
- if (issue.reason === 'bundled') currentBundled.push(path.relative(index.root, issue.path));
1814
+ currentIssues.push(`${issue.reason}\0${path.relative(index.root, issue.path || index.root) || '.'}`);
1733
1815
  },
1734
1816
  onSkippedFile: (filePath) => {
1735
1817
  if (path.basename(filePath) === 'Cargo.toml') {
@@ -1746,16 +1828,19 @@ function isCacheStale(index) {
1746
1828
  if (!gitignoreRules) gitignoreRules = parseGitignore(index.root, gitListingOnce());
1747
1829
  globOpts.gitignoreRules = gitignoreRules;
1748
1830
  globOpts.trackedPaths = gitTrackedPaths(index.root, gitListingOnce());
1749
- const configExclude = index.config.exclude || [];
1750
- if (configExclude.length > 0) {
1751
- globOpts.ignores = [...DEFAULT_IGNORES, ...configExclude];
1752
- }
1753
1831
  const currentFiles = expandGlob(pattern, globOpts);
1754
- const cachedBundled = (index.discoveryIssues || [])
1755
- .filter(issue => issue.reason === 'bundled').map(issue => issue.relativePath).sort();
1756
- currentBundled.sort();
1757
- if (cachedBundled.length !== currentBundled.length ||
1758
- cachedBundled.some((value, i) => value !== currentBundled[i])) return true;
1832
+ // Every path the walk reports as not indexed, with its reason: a file
1833
+ // that became a bundle, crossed the size limit, entered or left an
1834
+ // excluded path changes what a claim ranges over. Issues the build
1835
+ // records about itself (an unusable config, a failed git listing) are
1836
+ // not the walk's to compare.
1837
+ const BUILD_ISSUES = new Set(['invalid-config', 'git-listing-failed']);
1838
+ const cachedIssues = [...new Set((index.discoveryIssues || [])
1839
+ .filter(issue => !BUILD_ISSUES.has(issue.reason))
1840
+ .map(issue => `${issue.reason}\0${issue.relativePath || '.'}`))].sort();
1841
+ const walkedIssues = [...new Set(currentIssues)].sort();
1842
+ if (cachedIssues.length !== walkedIssues.length ||
1843
+ cachedIssues.some((value, i) => value !== walkedIssues[i])) return true;
1759
1844
  const cachedPaths = new Set(index.files.keys());
1760
1845
  const currentPaths = new Set(currentFiles);
1761
1846