ucn 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) 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 +334 -316
  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 +67 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/doctor.js +13 -3
  21. package/core/output/endpoints.js +8 -3
  22. package/core/output/extraction.js +12 -1
  23. package/core/output/find.js +23 -9
  24. package/core/output/graph.js +32 -9
  25. package/core/output/refactoring.js +147 -49
  26. package/core/output/reporting.js +104 -5
  27. package/core/output/search.js +30 -3
  28. package/core/output/shared.js +31 -4
  29. package/core/output/tracing.js +22 -11
  30. package/core/parser.js +8 -6
  31. package/core/project.js +167 -7
  32. package/core/registry.js +20 -16
  33. package/core/reporting.js +220 -84
  34. package/core/search.js +270 -55
  35. package/core/shared.js +285 -1
  36. package/core/stacktrace.js +23 -1
  37. package/core/tracing.js +278 -36
  38. package/core/verify.js +352 -349
  39. package/languages/go.js +29 -17
  40. package/languages/index.js +56 -0
  41. package/languages/java.js +133 -16
  42. package/languages/javascript.js +215 -27
  43. package/languages/python.js +113 -47
  44. package/languages/rust.js +89 -26
  45. package/languages/utils.js +35 -7
  46. package/mcp/server.js +72 -31
  47. package/package.json +4 -1
package/core/bridge.js CHANGED
@@ -23,6 +23,7 @@
23
23
  'use strict';
24
24
 
25
25
  const fs = require('fs');
26
+ const { codeUnitCompare } = require('./shared');
26
27
  const path = require('path');
27
28
  const { getCachedCalls } = require('./callers');
28
29
  const { langTraits } = require('../languages');
@@ -353,10 +354,10 @@ function extractServerRoutes(index) {
353
354
 
354
355
  // Sort deterministically (file, line, method, path)
355
356
  routes.sort((a, b) => {
356
- if (a.file !== b.file) return a.file.localeCompare(b.file);
357
+ if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
357
358
  if (a.line !== b.line) return a.line - b.line;
358
- if (a.method !== b.method) return a.method.localeCompare(b.method);
359
- return a.path.localeCompare(b.path);
359
+ if (a.method !== b.method) return codeUnitCompare(a.method, b.method);
360
+ return codeUnitCompare(a.path, b.path);
360
361
  });
361
362
 
362
363
  // Cache it
@@ -726,10 +727,10 @@ function extractClientRequests(index) {
726
727
 
727
728
  // Stable sort
728
729
  requests.sort((a, b) => {
729
- if (a.file !== b.file) return a.file.localeCompare(b.file);
730
+ if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
730
731
  if (a.line !== b.line) return a.line - b.line;
731
- if (a.method !== b.method) return a.method.localeCompare(b.method);
732
- return a.path.localeCompare(b.path);
732
+ if (a.method !== b.method) return codeUnitCompare(a.method, b.method);
733
+ return codeUnitCompare(a.path, b.path);
733
734
  });
734
735
 
735
736
  if (!index._endpointsCache) index._endpointsCache = {};
@@ -887,13 +888,13 @@ function bridgeEndpoints(index) {
887
888
  // For each (request) keep all matches but sort with best first
888
889
  bridges.sort((a, b) => {
889
890
  // Group by request first
890
- const reqCmpFile = a.request.file.localeCompare(b.request.file);
891
+ const reqCmpFile = codeUnitCompare(a.request.file, b.request.file);
891
892
  if (reqCmpFile !== 0) return reqCmpFile;
892
893
  if (a.request.line !== b.request.line) return a.request.line - b.request.line;
893
894
  // Then by confidence desc
894
895
  if (a.confidence !== b.confidence) return b.confidence - a.confidence;
895
896
  // Then by route file/line
896
- if (a.route.file !== b.route.file) return a.route.file.localeCompare(b.route.file);
897
+ if (a.route.file !== b.route.file) return codeUnitCompare(a.route.file, b.route.file);
897
898
  return a.route.line - b.route.line;
898
899
  });
899
900
 
@@ -1067,6 +1068,10 @@ function endpoints(index, options = {}) {
1067
1068
  }
1068
1069
 
1069
1070
  return {
1071
+ // Advisory when bridging (v4 two-tier surface): route↔request
1072
+ // matching is heuristic (per-match tiers EXACT/PARTIAL/UNCERTAIN),
1073
+ // not a verified claim. Route/request EXTRACTION is AST-based.
1074
+ ...(opts.bridge && { advisory: 'heuristic-route-matching' }),
1070
1075
  routes,
1071
1076
  requests,
1072
1077
  bridges,
package/core/cache.js CHANGED
@@ -51,7 +51,65 @@ const UCN_VERSION = require('../package.json').version;
51
51
  // node's line (the #201/RUST-2 name-node convention — multi-line receivers
52
52
  // like `(&pkg.Name{...}).String()` reported the chain-start line; Go was the
53
53
  // only parser still keying calls off the call node's start).
54
- const CACHE_FORMAT_VERSION = 26;
54
+ // v27 (fix #224): Python from-import submodules — `from . import jobs` binds
55
+ // jobs.py as a plain NAME; graph-build resolves the composed submodule
56
+ // specifier ('.jobs') into fileEntry.moduleResolved and adds the import edge,
57
+ // so submodule receivers behave like `import jobs` module receivers
58
+ // (persisted moduleResolved/importGraph shapes gain entries).
59
+ // v28 (fix #227): canonical index order — everything persisted is written from
60
+ // a canonicalized state (_canonicalizeOrder: files/callsCache by path, defs
61
+ // arrays by (relativePath, startLine, type, className), calleeIndex sorted),
62
+ // so fresh-build, cache-load, and incremental-rebuild states are
63
+ // byte-equivalent and command output no longer depends on cache history.
64
+ // v29 (fix #229): Rust impl members and Java class methods carry method-level
65
+ // `generics` — generic-param receiver types (t.wipe() on TStore: Wipe) resolve
66
+ // against the enclosing declaration instead of excluding as type mismatches.
67
+ // v30 (fix #230): TS parameter-property modifiers (protected/readonly/...)
68
+ // and parameter decorators are no longer recorded as parameter DEFAULTS in
69
+ // paramsStructured.
70
+ // v31 (fix #231): Java try-with-resources declarations type receivers —
71
+ // `try (Res r = new Res())` records receiverType on r.use() like a plain
72
+ // declared-type local (#220(7) typing-sources family).
73
+ // v32 (fix #238): super(...)/this(...) constructor-delegation call records
74
+ // (JS/TS 'constructor' with receiver 'super'; Java under the target class
75
+ // name), Java enum-constant constructor invocations (RED(1)), and Go/Rust
76
+ // zero-param signatures record '' instead of the '...' unknown sentinel.
77
+ // v33 (fix #240): persisted importGraph/exportGraph/moduleResolved content
78
+ // changed — Java wildcard imports link EVERY file directly in the package
79
+ // (non-recursively; subpackage false links dropped), Rust flat-layout crates
80
+ // (no src/) resolve crate:: paths, and super::/crate:: item imports fall back
81
+ // to the parent module FILE (mod.rs / <dir>.rs / lib.rs / main.rs).
82
+ // v34 (fix #241): Java/Python zero-param signatures record '' instead of the
83
+ // '...' unknown sentinel (completes #238's Go/Rust fix), and Rust struct field
84
+ // member symbols carry their own visibility in `modifiers` (pub/pub(crate)/...)
85
+ // so export listings judge fields per-symbol.
86
+ // v35 (fix #245): persisted exports/exportDetails/imports content changed —
87
+ // the JS/TS export scanner records `export abstract class`, `export declare`
88
+ // wrappers, and `export namespace` (the namespace is the importable name),
89
+ // and TS import-equals (`import x = require('./y')`) produces an import
90
+ // record (the dependency edge was invisible to all filedeps commands).
91
+ // v36 (fix #246): persisted importGraph/moduleResolved content changed —
92
+ // Rust own-package-name imports (`use my_crate::helper` in tests/, benches/,
93
+ // examples/) resolve into the package's source tree via the Cargo [package]
94
+ // name, so integration-test dependency edges exist.
95
+ // v37 (fix #247): JS/TS class members carry `private`/`protected`
96
+ // accessibility keywords in `modifiers` (deadcode's exported-member check
97
+ // treated every TS member as implicitly public).
98
+ // v38 (fix #248): Go generic receivers normalize to the type name
99
+ // (`Pair[K, V]` → `Pair` in receiver/className); Rust trait members carry
100
+ // the trait's own visibility instead of the non-Rust 'public', and Rust
101
+ // impl methods record async/unsafe/const/extern qualifiers in modifiers.
102
+ // v39 (fix #249): JS/TS modifiers come from AST tokens, not first-line
103
+ // text — cached symbols may carry export/async/default fabricated from
104
+ // string literals and comments.
105
+ // v40 (fix #251): Java field members carry visibility modifiers, enum
106
+ // constants carry their implicit public/static/final, and Python type
107
+ // aliases (PEP 695 + TypeAlias annotations) are indexed as 'type' symbols.
108
+ // v41 (fix #252): CJS export object maps index their shorthand/pair
109
+ // function properties (module.exports = { doThing(x){} }) and list them in
110
+ // exports; member-expression assignments record RHS identifier function
111
+ // references (window.onload = handler).
112
+ const CACHE_FORMAT_VERSION = 41;
55
113
 
56
114
  /**
57
115
  * Save index to cache file
@@ -281,7 +339,16 @@ function loadCache(index, cachePath) {
281
339
  return false;
282
340
  }
283
341
 
284
- const root = cacheData.root || index.root;
342
+ // Rehydrate against the CURRENT root, never cacheData.root the
343
+ // cache stores relative paths precisely so a moved/copied project
344
+ // keeps its cache. Using the recorded root re-attached every path to
345
+ // the ORIGINAL directory: the staleness check then forced a rebuild
346
+ // of files/symbols, but _reachableSymbols survived with old-root
347
+ // keys (the fingerprint is path-blind), poisoning reachability notes
348
+ // and --unreachable-only permanently (fix #249, G9-parity P1 —
349
+ // repro: cp -r projA projB). The calls-shard loader already used
350
+ // index.root.
351
+ const root = index.root;
285
352
  // Fast path conversion: string concat is ~70x faster than path.join for
286
353
  // cache-stored relative paths (no '..' segments). On Windows, path.relative
287
354
  // produces backslash paths, so rootPrefix uses the native separator.
@@ -325,6 +392,12 @@ function loadCache(index, cachePath) {
325
392
  }
326
393
  }
327
394
 
395
+ // Canonical order (see ProjectIndex._canonicalizeOrder): the loop above
396
+ // rebuilds fileEntry.symbols/bindings in NAME-MAP order, which differs
397
+ // from build's parse order — canonicalize so a loaded index is
398
+ // byte-equivalent to a freshly built one before anything derives from it.
399
+ index._canonicalizeOrder();
400
+
328
401
  // Reconstruct graphs: relative paths → absolute paths (as Sets)
329
402
  // Uses string concat (toAbs) instead of path.join — 70x faster on 464K edges
330
403
  const absGraph = (data) => {
@@ -421,11 +494,13 @@ function loadCache(index, cachePath) {
421
494
  * @returns {boolean} - True if cache needs rebuilding
422
495
  */
423
496
  function isCacheStale(index) {
424
- // Ultra-fast path: skip full check if last confirmed-fresh < 2s ago (covers MCP burst calls).
425
- // Only uses _lastFreshAt (set at the end of a successful full check), not cache save timestamp.
426
- if (index._lastFreshAt && Date.now() - index._lastFreshAt < 2000) {
427
- return false;
428
- }
497
+ // Modified/deleted detection (stat sweep) runs UNCONDITIONALLY agents
498
+ // edit a file and re-query through MCP within seconds, and a stale answer
499
+ // presented as fresh is the worst trust failure the tool can produce.
500
+ // The 2s freshness window below shields only the expensive directory
501
+ // walk (new-file detection): a brand-new file queried within 2s of the
502
+ // last full check is a far rarer race than an edit, and the walk is the
503
+ // part that costs real time on large repos.
429
504
 
430
505
  // Fast path: check cached files for modifications/deletions first (stat-only).
431
506
  // This returns early without the expensive directory walk when any file changed.
@@ -454,6 +529,13 @@ function isCacheStale(index) {
454
529
  }
455
530
  }
456
531
 
532
+ // Ultra-fast skip for the SLOW path only: last confirmed-fresh < 2s ago
533
+ // (covers MCP burst calls). Uses _lastFreshAt (set at the end of a
534
+ // successful full check), never the cache save timestamp.
535
+ if (index._lastFreshAt && Date.now() - index._lastFreshAt < 2000) {
536
+ return false;
537
+ }
538
+
457
539
  // Slow path: glob the project to detect new files added since last build.
458
540
  // Only reached when all cached files are unchanged.
459
541
  const pattern = detectProjectPattern(index.root);
@@ -510,13 +592,14 @@ function _prepareCallsCache(index) {
510
592
 
511
593
  /**
512
594
  * Load callsCache from separate file on demand.
513
- * Only loads if callsCache is empty (not already populated from inline or prior load).
595
+ * Merges under existing entries (first writer wins) anything already in
596
+ * memory came from a fresh parse of current disk content, so persisted data
597
+ * must never replace it (fix #227).
514
598
  * @param {object} index - ProjectIndex instance
515
- * @returns {boolean} - True if loaded successfully
599
+ * @returns {boolean} - True if entries are available after the load
516
600
  */
517
601
  function loadCallsCache(index) {
518
- if (index.callsCache.size > 0) return true; // Already populated
519
- if (index._callsCacheLoaded) return false; // Already attempted, file didn't exist
602
+ if (index._callsCacheLoaded) return index.callsCache.size > 0;
520
603
  index._callsCacheLoaded = true;
521
604
 
522
605
  // If manifest was prepared lazily, load all shards now
@@ -530,22 +613,24 @@ function loadCallsCache(index) {
530
613
  // Legacy format: single calls-cache.json
531
614
  const callsCacheFile = index._callsCacheLegacyFile ||
532
615
  path.join(index.root, '.ucn-cache', 'calls-cache.json');
533
- if (!fs.existsSync(callsCacheFile)) return false;
616
+ if (!fs.existsSync(callsCacheFile)) return index.callsCache.size > 0;
534
617
 
535
618
  try {
536
619
  const data = JSON.parse(fs.readFileSync(callsCacheFile, 'utf-8'));
537
620
  if (Array.isArray(data)) {
538
- const absData = data.map(([relPath, entry]) => {
621
+ for (const [relPath, entry] of data) {
622
+ if (!relPath || !entry) continue;
539
623
  const absPath = path.isAbsolute(relPath) ? relPath : path.join(index.root, relPath);
540
- return [absPath, entry];
541
- });
542
- index.callsCache = new Map(absData);
543
- return true;
624
+ if (!index.callsCache.has(absPath)) {
625
+ index.callsCache.set(absPath, entry);
626
+ }
627
+ }
628
+ return index.callsCache.size > 0;
544
629
  }
545
630
  } catch (e) {
546
631
  // Corrupted file — ignore
547
632
  }
548
- return false;
633
+ return index.callsCache.size > 0;
549
634
  }
550
635
 
551
636
  /**
@@ -576,7 +661,12 @@ function _loadCallsShard(index, hash) {
576
661
  for (const [relPath, entry] of data) {
577
662
  if (!relPath || !entry) continue;
578
663
  const absPath = path.isAbsolute(relPath) ? relPath : toAbsShard(relPath);
579
- index.callsCache.set(absPath, entry);
664
+ // First writer wins: an entry already in memory came from a fresh
665
+ // parse of current disk content (or an earlier load) — never
666
+ // clobber it with persisted shard data (fix #227).
667
+ if (!index.callsCache.has(absPath)) {
668
+ index.callsCache.set(absPath, entry);
669
+ }
580
670
  }
581
671
  } catch (e) {
582
672
  // Corrupted shard — skip