ucn 5.3.2 → 5.3.4

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.
@@ -39,7 +39,7 @@ answer, a notice stating the full size and limit, and the accounting/contract
39
39
  lines needed to interpret what remains; the requested limit includes all
40
40
  three. The text block is the whole response on every surface.
41
41
 
42
- Persistent indexes live in a per-user, project-keyed cache rather than the analyzed repository. Set `UCN_CACHE_DIR` to override the cache root; CLI `--no-cache` bypasses persistence and `--clear-cache` removes the current project's cache. Legacy `<project>/.ucn-cache` directories are migrated on first use.
42
+ Persistent indexes live in a per-user, project-keyed cache rather than the analyzed repository. Set `UCN_CACHE_DIR` to override the cache root; CLI `--no-cache` bypasses persistence and `--clear-cache` removes the current project's cache. Legacy `<project>/.ucn-cache` directories are migrated on first use. Concurrent invocations on a cold cache share one build: the first process takes a build lock, the others wait for its cache (`Waiting for another ucn process building the index...` on stderr) instead of rebuilding; a lock left by a dead process is broken automatically.
43
43
 
44
44
  Supported source families are JavaScript/TypeScript/TSX, Python, Go, Rust, Java, C, C++, C#, and HTML inline JavaScript/event handlers. C/C++ uses `compile_commands.json` when available to classify headers and resolve include paths. Recoverable preprocessor branches contribute AST-proven source facts, so a single selected configuration does not silently erase definitions or calls; disagreeing conditional macro identities stay visible as unverified. C++ resolution uses namespace ownership, static overload shape (including arrays), and macro-parameter requalification. C# resolution uses declared property/field receiver types plus overload and hiding discipline. This is portable AST analysis, not a compiler build; macros, templates, generated code, reflection, and external dependency semantics can remain unverified.
45
45
 
@@ -161,7 +161,15 @@ Treat `deadcode` as a candidate generator. Before deletion, inspect `usages`, `i
161
161
  (JSX children, HTML markup and attributes) in an `OTHER TEXT` section unless
162
162
  `--code-only` is set, so it lists every line the `ACCOUNT` counts. This is a
163
163
  literal-name inventory, not exact target binding. Identifier boundaries are
164
- Unicode-aware: `hit` never matches inside `hitΔ`.
164
+ Unicode-aware and match `grep -w`: `hit` never matches inside `hitΔ`, while
165
+ `$` is a boundary (`buy${...}`, `$fail`, `ws$close()` all count).
166
+
167
+ Aliased and qualified calls resolve in every language: Rust `use m::f as g; g()`
168
+ is a caller of `f` (listed as a beyond-text caller, since the line holds no
169
+ target token); Java `pkg.Type.method()` and C# `Ns.Type.Method()` /
170
+ `using T = Ns.Type; T.Method()` pick the type the qualifier names when several
171
+ same-name types exist. A qualifier the resolver cannot place stays visible as
172
+ `method-ambiguous`, never confirmed by first-definition order.
165
173
 
166
174
  `endpoints` recognizes client receivers by evidence (a receiver typed to an
167
175
  HTTP client class, or bound to a pytest fixture that constructs one), not only
package/cli/index.js CHANGED
@@ -766,8 +766,18 @@ function runProjectCommand(rootDir, command, arg) {
766
766
  // If cache was loaded but stale, force rebuild to avoid duplicates
767
767
  let needsCacheSave = false;
768
768
  if (!usedCache) {
769
- index.build(null, { quiet: flags.quiet, forceRebuild: cacheWasLoaded, followSymlinks: flags.followSymlinks, maxFiles: flags.maxFiles, workers: flags.workers });
770
- needsCacheSave = flags.cache;
769
+ const buildOpts = { quiet: flags.quiet, forceRebuild: cacheWasLoaded, followSymlinks: flags.followSymlinks, maxFiles: flags.maxFiles, workers: flags.workers };
770
+ if (flags.cache && !flags.maxFiles) {
771
+ // Cross-process build lock (fix #354): concurrent cold-cache
772
+ // invocations share one build instead of each rebuilding.
773
+ index.buildCached(buildOpts, {
774
+ onWait: () => { if (!flags.quiet) console.error('Waiting for another ucn process building the index...'); },
775
+ });
776
+ needsCacheSave = false; // buildCached saved (or loaded the other process's cache)
777
+ } else {
778
+ index.build(null, buildOpts);
779
+ needsCacheSave = flags.cache;
780
+ }
771
781
  }
772
782
 
773
783
  try {
@@ -986,13 +996,10 @@ function runInteractive(rootDir) {
986
996
  if (flags.cache) {
987
997
  const loaded = !flags.clearCache && index.loadCache();
988
998
  iCacheFresh = loaded && !index.isCacheStale();
989
- if (!iCacheFresh && loaded) {
990
- index.build(null, { quiet: true, forceRebuild: true, workers: flags.workers });
991
- } else if (!iCacheFresh) {
992
- index.build(null, { quiet: true, workers: flags.workers });
993
- }
994
999
  if (!iCacheFresh) {
995
- try { index.saveCache(); } catch (_) { /* best-effort */ }
1000
+ index.buildCached({ quiet: true, forceRebuild: !!loaded, workers: flags.workers }, {
1001
+ onWait: () => console.log('Waiting for another ucn process building the index...'),
1002
+ });
996
1003
  }
997
1004
  } else {
998
1005
  index.build(null, { quiet: true, workers: flags.workers });
@@ -1113,15 +1120,19 @@ Flags can be added per-command: show myFunc --sections=source,callers
1113
1120
  // appeared in neighbouring answers (UCN5-044).
1114
1121
  if (index.isCacheStale()) {
1115
1122
  console.log('Source changed; rebuilding index...');
1116
- index.build(null, {
1123
+ const rebuildOpts = {
1117
1124
  quiet: true,
1118
1125
  forceRebuild: true,
1119
1126
  followSymlinks: flags.followSymlinks,
1120
1127
  maxFiles: flags.maxFiles,
1121
1128
  workers: flags.workers,
1122
- });
1123
- if (flags.cache) {
1124
- try { index.saveCache(); } catch (_) { /* best-effort */ }
1129
+ };
1130
+ if (flags.cache && !flags.maxFiles) {
1131
+ index.buildCached(rebuildOpts, {
1132
+ onWait: () => console.log('Waiting for another ucn process building the index...'),
1133
+ });
1134
+ } else {
1135
+ index.build(null, rebuildOpts);
1125
1136
  }
1126
1137
  console.log(`Index ready: ${index.files.size} files, ${index.symbols.size} unique symbol names`);
1127
1138
  }
package/core/cache.js CHANGED
@@ -256,6 +256,14 @@ function pruneUserCache({ force = false, now = Date.now() } = {}) {
256
256
  root = data.root || null;
257
257
  touched = Math.max(touched, Number(data.timestamp) || 0);
258
258
  } catch (_) { /* malformed/incomplete cache expires below */ }
259
+ // fix #354: a project whose FIRST build is in progress has no
260
+ // index.json yet, only a live build.lock — another process's prune
261
+ // must not delete the lock (or the dir) out from under the builder.
262
+ const lockPath = path.join(dir, BUILD_LOCK_FILE);
263
+ if (!root && fs.existsSync(lockPath) && !_lockIsStale(lockPath)) {
264
+ entries.push({ dir, touched: now, bytes: null });
265
+ continue;
266
+ }
259
267
  if (!root || !fs.existsSync(root) || now - touched > CACHE_TTL_MS) {
260
268
  try {
261
269
  fs.rmSync(dir, { recursive: true, force: true });
@@ -699,7 +707,7 @@ function clearAllCaches() {
699
707
  // v212 (fix #342): extendsGraph/extendedByGraph no longer persisted (rebuilt on load).
700
708
  // v213 (fix #348): Python from-import records carry per-name `renames` so
701
709
  // import bindings pair each alias with its own module.
702
- const CACHE_FORMAT_VERSION = 213;
710
+ const CACHE_FORMAT_VERSION = 214;
703
711
  const USAGE_CACHE_FILE = 'usage-results.json';
704
712
 
705
713
  /**
@@ -1494,7 +1502,101 @@ function _computeReachabilityFingerprint(index) {
1494
1502
  return `${fileCount}:${symbolCount}:${sample}`;
1495
1503
  }
1496
1504
 
1505
+ const BUILD_LOCK_FILE = 'build.lock';
1506
+ const BUILD_LOCK_STALE_MS = 15 * 60 * 1000;
1507
+ const BUILD_LOCK_WAIT_MS = 15 * 60 * 1000;
1508
+ const BUILD_LOCK_POLL_MS = 150;
1509
+
1510
+ function _sleepSync(ms) {
1511
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1512
+ }
1513
+
1514
+ function _pidAlive(pid) {
1515
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1516
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
1517
+ }
1518
+
1519
+ function _readLock(lockPath) {
1520
+ try { return JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch (_) { return null; }
1521
+ }
1522
+
1523
+ function _lockIsStale(lockPath) {
1524
+ const info = _readLock(lockPath);
1525
+ if (!info) {
1526
+ // Unreadable or half-written: judge by mtime alone
1527
+ try { return Date.now() - fs.statSync(lockPath).mtimeMs > BUILD_LOCK_STALE_MS; } catch (_) { return true; }
1528
+ }
1529
+ if (info.pid && !_pidAlive(info.pid)) return true;
1530
+ return Date.now() - (info.startedAt || 0) > BUILD_LOCK_STALE_MS;
1531
+ }
1532
+
1533
+ /**
1534
+ * Cross-process build lock (fix #354): N concurrent cold-cache invocations
1535
+ * used to rebuild the whole index N times at once (6 processes on a 474k-line
1536
+ * repo: 9s each alone, 363s each together — the stampede fires after every
1537
+ * version bump, because the bump invalidates every cache). One process takes
1538
+ * the lock and builds; the others wait, then load the cache it saved. A lock
1539
+ * whose holder is dead, or older than 15 minutes, is broken. Waiters give up
1540
+ * after 15 minutes and build themselves (a wedged holder must never make the
1541
+ * tool hang forever).
1542
+ *
1543
+ * @returns {{ built: boolean, waited: boolean }}
1544
+ */
1545
+ function buildWithLock(index, buildOpts = {}, options = {}) {
1546
+ const cacheDir = getProjectCacheDir(index.root);
1547
+ const lockPath = path.join(cacheDir, BUILD_LOCK_FILE);
1548
+ fs.mkdirSync(cacheDir, { recursive: true });
1549
+ const deadline = Date.now() + BUILD_LOCK_WAIT_MS;
1550
+ let announced = false;
1551
+ for (;;) {
1552
+ let fd = null;
1553
+ try {
1554
+ fd = fs.openSync(lockPath, 'wx');
1555
+ } catch (e) {
1556
+ if (e.code !== 'EEXIST') throw e;
1557
+ }
1558
+ if (fd !== null) {
1559
+ try {
1560
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
1561
+ fs.closeSync(fd);
1562
+ index.build(null, buildOpts);
1563
+ if (options.save !== false) {
1564
+ try { saveCache(index); } catch (_) { /* best-effort */ }
1565
+ }
1566
+ return { built: true, waited: announced };
1567
+ } finally {
1568
+ try { fs.unlinkSync(lockPath); } catch (_) { /* already gone */ }
1569
+ }
1570
+ }
1571
+ if (_lockIsStale(lockPath)) {
1572
+ try { fs.unlinkSync(lockPath); } catch (_) { /* raced */ }
1573
+ continue;
1574
+ }
1575
+ if (Date.now() > deadline) {
1576
+ index.build(null, buildOpts);
1577
+ if (options.save !== false) {
1578
+ try { saveCache(index); } catch (_) { /* best-effort */ }
1579
+ }
1580
+ return { built: true, waited: true };
1581
+ }
1582
+ if (!announced) {
1583
+ announced = true;
1584
+ if (typeof options.onWait === 'function') options.onWait(_readLock(lockPath));
1585
+ }
1586
+ _sleepSync(BUILD_LOCK_POLL_MS);
1587
+ if (!fs.existsSync(lockPath)) {
1588
+ // The holder finished: its cache is the answer unless it is
1589
+ // already stale again (or it never saved one)
1590
+ if (options.reload !== false && loadCache(index) && !isCacheStale(index)) {
1591
+ return { built: false, waited: true };
1592
+ }
1593
+ // fall through: take the lock ourselves
1594
+ }
1595
+ }
1596
+ }
1597
+
1497
1598
  module.exports = {
1599
+ buildWithLock,
1498
1600
  saveCache, saveUsageCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
1499
1601
  getUserCacheRoot, getProjectCacheDir, getProjectCachePath,
1500
1602
  getLegacyProjectCacheDir, migrateLegacyProjectCache, clearProjectCache,
package/core/callers.js CHANGED
@@ -10,7 +10,8 @@ const path = require('path');
10
10
  const crypto = require('crypto');
11
11
  const { detectLanguage, getParser, getLanguageAdapter, langTraits } = require('../languages');
12
12
  const { isTestFile } = require('./discovery');
13
- const { NON_CALLABLE_TYPES, isOverrideMarked, codeUnitCompare, isTestPath } = require('./shared');
13
+ const { NON_CALLABLE_TYPES, isOverrideMarked, codeUnitCompare, isTestPath, CALLABLE_SYMBOL_KINDS } = require('./shared');
14
+ const { _resolveJavaPackageImport } = require('./graph-build');
14
15
  const { scoreEdge, tierForResolution, TIER } = require('./confidence');
15
16
  const { findGoModule, resolveRustImport } = require('./imports');
16
17
 
@@ -634,6 +635,17 @@ function findCallers(index, name, options = {}) {
634
635
  langTraits(fileEntry.language)?.typeSystem === 'structural';
635
636
 
636
637
  for (let call of calls) {
638
+ // fix #353: C# `Beta.Helper.Widget()` — the parser records a
639
+ // field hop rooted at `this` (Beta is no local). When the
640
+ // prefix names a project NAMESPACE that declares the last
641
+ // segment as a type, the receiver is that type, namespace-
642
+ // qualified; the hop shape would only ever fail the field
643
+ // walk and route method-ambiguous.
644
+ if (fileEntry.language === 'csharp') {
645
+ const rewritten = _csharpNamespaceQualifiedReceiver(index, call,
646
+ index.findEnclosingFunction(filePath, call.line, true));
647
+ if (rewritten) call = rewritten;
648
+ }
637
649
  // Skip if not matching our target name (also check alias resolution)
638
650
  let calledAs = null; // surface name when matched via an import/export rename
639
651
  const typeQualifierReference = targetIsTypeQuery &&
@@ -2808,6 +2820,16 @@ function findCallers(index, name, options = {}) {
2808
2820
  const paired = nameBindings.filter(b => b.alias === call.name);
2809
2821
  if (paired.length > 0) nameBindings = paired;
2810
2822
  }
2823
+ // Scope-granular import bindings (fix #352): a
2824
+ // function-local `from x import name` binds the name for
2825
+ // ITS function only. Three such imports in one file used
2826
+ // to make three file-level bindings, so a bare call in any
2827
+ // of the functions scope-matched every pin (investment
2828
+ // run_validation: 6 defs, each claiming the others'
2829
+ // sites). The nearest enclosing binder owns the name; a
2830
+ // binding inside a function that does not enclose the
2831
+ // call is out of scope (#215 discipline, line-granular).
2832
+ nameBindings = _scopeImportBindings(fileEntry, nameBindings, call.line);
2811
2833
  const tFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
2812
2834
  // fix #215 (rich-measured: 225 builtin `print(...)` calls
2813
2835
  // confirmed against rich's def via file-level import edges):
@@ -4075,12 +4097,17 @@ function findCallers(index, name, options = {}) {
4075
4097
  if (call.isPathCall && receiverName) {
4076
4098
  receiverName = String(receiverName).split('::').pop();
4077
4099
  }
4100
+ let aliasResolvedFile = null;
4078
4101
  if (receiverName && !tTypes.has(receiverName)) {
4079
4102
  for (const im of (fileEntry.importBindings || [])) {
4080
4103
  if (im.name !== receiverName) continue;
4081
- const orig = String(im.module || '').split('::').pop();
4104
+ // fix #353: C# `using BH = Beta.Helper` (and Java
4105
+ // dotted paths) split on `.`; Rust paths on `::`.
4106
+ const orig = String(im.module || '').split(/::|\./).pop();
4082
4107
  if (orig && orig !== receiverName && tTypes.has(orig)) {
4083
4108
  receiverName = orig;
4109
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[im.module];
4110
+ if (rel) aliasResolvedFile = path.join(index.root, rel);
4084
4111
  break;
4085
4112
  }
4086
4113
  }
@@ -4108,7 +4135,14 @@ function findCallers(index, name, options = {}) {
4108
4135
  // name fallback above handles multi-definition names; this
4109
4136
  // covers single-definition targets that skip it.
4110
4137
  if (typeQualifiedReceiver) {
4111
- const identity = _resolveReceiverTypeIdentity(index, filePath, receiverName, targetDefs2, call.line);
4138
+ // fix #353: an alias binding that RESOLVED to a file is
4139
+ // the type's identity (`using BH = Beta.Helper`); a
4140
+ // parser-recorded qualifier (`beta.Helper`, `Beta.Helper`)
4141
+ // is a namespace/package hint for the resolver.
4142
+ const identity = aliasResolvedFile
4143
+ ? (targetDefs2.some(d => d.file === aliasResolvedFile) ? 'target' : 'other')
4144
+ : _resolveReceiverTypeIdentity(index, filePath, receiverName, targetDefs2, call.line,
4145
+ call.receiverIsTypeQualified ? call.receiverTypeQualifier : undefined);
4112
4146
  if (identity === 'other') {
4113
4147
  recordExcluded(filePath, call.line, 'path-type-mismatch');
4114
4148
  continue;
@@ -5344,6 +5378,12 @@ function findCallees(index, definition, options = {}) {
5344
5378
  for (let call of calls) {
5345
5379
  siteOrdinal++;
5346
5380
  const siteId = siteOrdinal;
5381
+ if (language === 'csharp') {
5382
+ // fix #353: `Beta.Helper.Widget()` — namespace-qualified type
5383
+ // receiver (see the findCallers twin).
5384
+ const rewritten = _csharpNamespaceQualifiedReceiver(index, call, def);
5385
+ if (rewritten) call = rewritten;
5386
+ }
5347
5387
  if (language === 'go' && call.isMethod &&
5348
5388
  !call.receiverType && call.receiverIndexField) {
5349
5389
  const indexedType = _goIndexedReceiverType(index, def.file, call);
@@ -9853,6 +9893,42 @@ function _projectTopLevelNames(index) {
9853
9893
  return names;
9854
9894
  }
9855
9895
 
9896
+ /**
9897
+ * Fix #352: restrict import bindings of a name to those in scope at a call
9898
+ * line. A binding whose import line sits inside a function body is local to
9899
+ * that function (Python function-body imports, JS function-scoped require);
9900
+ * the innermost enclosing binder wins, bindings in non-enclosing functions
9901
+ * are dropped, module-level bindings survive only when no enclosing function
9902
+ * binds the name. Bindings without a line (older records) are kept as-is.
9903
+ */
9904
+ function _scopeImportBindings(fileEntry, bindings, callLine) {
9905
+ if (!bindings || bindings.length < 2 || callLine == null) return bindings;
9906
+ if (!bindings.some(b => b.line != null && b.deferred)) return bindings;
9907
+ const scopes = (fileEntry.symbols || []).filter(s =>
9908
+ s.startLine != null && s.endLine != null && s.endLine > s.startLine &&
9909
+ CALLABLE_SYMBOL_KINDS.has(s.type));
9910
+ const innermost = line => {
9911
+ let best = null;
9912
+ for (const s of scopes) {
9913
+ if (line < s.startLine || line > s.endLine) continue;
9914
+ if (!best || (s.endLine - s.startLine) < (best.endLine - best.startLine)) best = s;
9915
+ }
9916
+ return best;
9917
+ };
9918
+ const local = [];
9919
+ const moduleLevel = [];
9920
+ for (const b of bindings) {
9921
+ if (b.line == null) { moduleLevel.push(b); continue; }
9922
+ const scope = innermost(b.line);
9923
+ if (!scope) { moduleLevel.push(b); continue; }
9924
+ if (callLine < scope.startLine || callLine > scope.endLine) continue;
9925
+ local.push({ b, size: scope.endLine - scope.startLine });
9926
+ }
9927
+ if (local.length === 0) return moduleLevel;
9928
+ const nearest = Math.min(...local.map(l => l.size));
9929
+ return local.filter(l => l.size === nearest).map(l => l.b);
9930
+ }
9931
+
9856
9932
  /**
9857
9933
  * Is an UNRESOLVED module specifier a resolver gap rather than externality
9858
9934
  * evidence? (fix #337b) Relative specifiers and first segments naming a
@@ -9980,6 +10056,32 @@ function _isGenericParamReceiverType(index, filePath, line, typeName) {
9980
10056
  return _isEnclosingGenericParam(index, filePath, line, typeName);
9981
10057
  }
9982
10058
 
10059
+ /**
10060
+ * fix #353: C# namespace-qualified type receivers. `Beta.Helper.Widget()` is
10061
+ * recorded by the parser as a this-rooted field hop (Beta is not a local);
10062
+ * when the dotted prefix names a project namespace (exactly, or relative to
10063
+ * the call's own namespace) that declares the last segment as a type, the
10064
+ * call is a type-qualified static call on that type. Returns a rewritten
10065
+ * record or null (unknown prefixes keep the parser's shape).
10066
+ */
10067
+ function _csharpNamespaceQualifiedReceiver(index, call, enclosing) {
10068
+ if (!call.isMethod || call.receiverType || !Array.isArray(call.receiverFields) ||
10069
+ call.receiverFields.length < 2 || call.receiverRoot !== 'this') return null;
10070
+ const typeName = call.receiverFields[call.receiverFields.length - 1];
10071
+ if (!/^[A-Z]/.test(typeName)) return null;
10072
+ const prefix = call.receiverFields.slice(0, -1).join('.');
10073
+ const typeDefs = (index.symbols.get(typeName) || []).filter(d =>
10074
+ IDENTITY_TYPE_KINDS.has(d.type) && d.namespace);
10075
+ if (typeDefs.length === 0) return null;
10076
+ const enclosingNs = enclosing?.namespace || null;
10077
+ const candidates = enclosingNs ? [prefix, `${enclosingNs}.${prefix}`] : [prefix];
10078
+ const ns = candidates.find(c => typeDefs.some(d => d.namespace === c));
10079
+ if (!ns) return null;
10080
+ const { receiverRoot, receiverField, receiverFields, receiverRootType, receiverRootNamespace, ...rest } = call;
10081
+ void receiverRoot; void receiverField; void receiverFields; void receiverRootType; void receiverRootNamespace;
10082
+ return { ...rest, receiver: typeName, receiverIsTypeQualified: true, receiverTypeQualifier: ns };
10083
+ }
10084
+
9983
10085
  /**
9984
10086
  * Java same-package check across Maven/Gradle source roots (fix #246):
9985
10087
  * src/main/java/<pkg> and src/test/java/<pkg> hold the SAME package —
@@ -10019,6 +10121,16 @@ function _resolveReceiverTypeIdentity(index, filePath, knownType, targetDefs, li
10019
10121
  // cannot resolve it. That is not exclusion evidence.
10020
10122
  return 'unknown';
10021
10123
  }
10124
+ if (language === 'java' && namespaceHint && /^[a-z_]/.test(namespaceHint)) {
10125
+ // fix #353: a lowercase dotted qualifier is a PACKAGE
10126
+ // (`beta.Helper.widget()`); the package + type name resolve to one
10127
+ // file exactly like an import of `beta.Helper` would. Unresolvable
10128
+ // packages (external, resolver gap) are never exclusion evidence.
10129
+ const resolved = _resolveJavaPackageImport(index, `${namespaceHint}.${knownType}`, null);
10130
+ if (!resolved) return 'unknown';
10131
+ return targetDefs.some(d => d.className === knownType && d.file === resolved)
10132
+ ? 'target' : 'other';
10133
+ }
10022
10134
  if (language === 'java' && line == null) {
10023
10135
  // Return-flow annotations are interpreted in the PRODUCER definition's
10024
10136
  // file. That origin is stronger than same-package lookup and is
@@ -10965,6 +11077,23 @@ function _calleeStructuralBindingRoute(index, fileEntry, call, language, binding
10965
11077
  let sawProjectish = false;
10966
11078
  let sawUnknown = false;
10967
11079
  for (const binding of bindings) {
11080
+ // fix #353 (Rust): `use alpha::widget as renamed; renamed()` — the
11081
+ // binding's module is the ITEM path; the item is its last segment
11082
+ // and the owning module file resolves through the Rust resolver.
11083
+ if (language === 'rust') {
11084
+ const segs = String(binding.module || '').split('::').filter(Boolean);
11085
+ const item = segs[segs.length - 1];
11086
+ const owners = _rustBindingResolvedFiles(index, fileEntry, fileEntry.path, binding);
11087
+ if (item && owners.size > 0) {
11088
+ sawProjectish = true;
11089
+ for (const moduleFile of owners) {
11090
+ const routed = _calleeExportDefinitions(index, moduleFile, item, language, call, {});
11091
+ for (const d of routed.matches) matches.set(`${d.file}:${d.startLine}`, d);
11092
+ if (routed.unknown) sawUnknown = true;
11093
+ }
11094
+ continue;
11095
+ }
11096
+ }
10968
11097
  const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[binding.module];
10969
11098
  if (!rel) {
10970
11099
  if (_unresolvedModuleIsGap(index, binding.module, binding)) {
@@ -11722,6 +11851,42 @@ function _nonCallableFieldMember(index, typeName, name, language) {
11722
11851
  * (#215): the class defined in this file or a file binding of the name —
11723
11852
  * an unbound capitalized receiver may be a parameter or local.
11724
11853
  */
11854
+ /**
11855
+ * fix #353: resolve the files that OWN a type-qualified static receiver from
11856
+ * the qualifier in the call (Java package / C# namespace), the file's alias
11857
+ * or name import binding of the receiver, or the caller's own namespace.
11858
+ * Returns a Set of absolute files, or null when nothing pins the owner.
11859
+ */
11860
+ function _qualifiedStaticOwnerFiles(index, fileEntry, call, typeName, language, def) {
11861
+ const files = new Set();
11862
+ const typeDefs = (index.symbols.get(typeName) || []).filter(d =>
11863
+ IDENTITY_TYPE_KINDS.has(d.type) && d.file);
11864
+ const qual = call.receiverIsTypeQualified ? call.receiverTypeQualifier : null;
11865
+ if (qual) {
11866
+ if (language === 'java' && /^[a-z_]/.test(qual)) {
11867
+ const resolved = _resolveJavaPackageImport(index, `${qual}.${typeName}`, null);
11868
+ if (resolved) files.add(resolved);
11869
+ return files.size > 0 ? files : null;
11870
+ }
11871
+ if (language === 'csharp') {
11872
+ const enclosingNs = def?.namespace || null;
11873
+ const candidates = enclosingNs ? [qual, `${enclosingNs}.${qual}`] : [qual];
11874
+ for (const d of typeDefs) {
11875
+ if (candidates.includes(d.namespace)) files.add(d.file);
11876
+ }
11877
+ return files.size > 0 ? files : null;
11878
+ }
11879
+ }
11880
+ for (const b of (fileEntry?.importBindings || [])) {
11881
+ const bindsReceiver = b.name === call.receiver || b.alias === call.receiver ||
11882
+ (b.name === typeName && !b.alias);
11883
+ if (!bindsReceiver) continue;
11884
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
11885
+ if (rel) files.add(path.join(index.root, rel));
11886
+ }
11887
+ return files.size > 0 ? files : null;
11888
+ }
11889
+
11725
11890
  /**
11726
11891
  * Namespace/module-container resolution (fix #254, W8 BUG-4 — verify's
11727
11892
  * BUG-BX rule brought into the engine, range-based): `Utils.slug()` where a
@@ -11835,7 +12000,8 @@ function _calleeTypeQualifiedReceiver(index, def, fileEntry, call, language) {
11835
12000
  if (typeDefs.length === 0) {
11836
12001
  for (const im of (fileEntry?.importBindings || [])) {
11837
12002
  if (im.name !== receiver) continue;
11838
- const orig = String(im.module || '').split('::').pop();
12003
+ // fix #353: C# `using BH = Beta.Helper` splits on `.`
12004
+ const orig = String(im.module || '').split(/::|\./).pop();
11839
12005
  if (orig && orig !== receiver && typeKindsOf(orig).length > 0) {
11840
12006
  receiver = orig;
11841
12007
  typeDefs = typeKindsOf(orig);
@@ -11877,9 +12043,30 @@ function _calleeTypeQualifiedReceiver(index, def, fileEntry, call, language) {
11877
12043
  _normalizedAliasBase(index, d)));
11878
12044
  if (bases.size === 1) candidateTypes.push(bases.values().next().value);
11879
12045
  }
11880
- const symbols = index.symbols.get(call.name) || [];
12046
+ const allSymbols = index.symbols.get(call.name) || [];
11881
12047
  const isCallable = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
11882
12048
  (s.type === 'field' && s.fieldType && /^func\b/.test(s.fieldType));
12049
+ // fix #353: the qualifier that is right there in the call owns the type —
12050
+ // `beta.Helper.widget()` (package), `Beta.Helper.Widget()` (namespace),
12051
+ // `using BH = Beta.Helper; BH.Widget()` (alias binding), `import
12052
+ // beta.Helper;` (name binding). Same-name types in other packages/
12053
+ // namespaces leave the candidate set BEFORE member-group construction
12054
+ // (the group dedupes identical signatures, so the first same-name type
12055
+ // used to swallow the second). An unresolvable qualifier keeps them all.
12056
+ const ownerFiles = (language === 'java' || language === 'csharp')
12057
+ ? _qualifiedStaticOwnerFiles(index, fileEntry, call, receiver, language, def)
12058
+ : null;
12059
+ // A package qualifier the resolver cannot place (`org.external.Helper`)
12060
+ // over a project type of the same name: unpinnable — visible, never
12061
+ // confirmed by first-definition selection (#206 discipline).
12062
+ if (language === 'java' && call.receiverIsTypeQualified &&
12063
+ call.receiverTypeQualifier && /^[a-z_]/.test(call.receiverTypeQualifier) &&
12064
+ !ownerFiles) {
12065
+ return { unverified: 'method-ambiguous' };
12066
+ }
12067
+ const symbols = ownerFiles
12068
+ ? allSymbols.filter(s => !candidateTypes.includes(s.className) || ownerFiles.has(s.file))
12069
+ : allSymbols;
11883
12070
  if (language === 'java' || language === 'csharp') {
11884
12071
  // Class-qualified Java/C# calls see the compiler member group on the
11885
12072
  // qualifier. The helper handles inherited slots and C# name hiding.
package/core/project.js CHANGED
@@ -1946,7 +1946,7 @@ class ProjectIndex {
1946
1946
  * the usage scan saw (and may have deliberately dropped), as opposed to
1947
1947
  * JSX children, HTML markup, or other non-code text (fix #350).
1948
1948
  */
1949
- isIdentifierAtPosition(content, lineNum, column, filePath) {
1949
+ isIdentifierAtPosition(content, lineNum, column, filePath, name) {
1950
1950
  const language = detectLanguage(filePath, this.root);
1951
1951
  if (!language) return false;
1952
1952
  try {
@@ -1954,7 +1954,12 @@ class ProjectIndex {
1954
1954
  safeParse(getParser(language), content);
1955
1955
  if (!tree) return false;
1956
1956
  const node = tree.rootNode.descendantForPosition({ row: lineNum - 1, column });
1957
- return !!node && /identifier|^name$|^word$/.test(node.type);
1957
+ if (!node || !/identifier|^name$|^word$/.test(node.type)) return false;
1958
+ // fix #352: the identifier node must BE the name — a hyphenated JSX
1959
+ // attribute (`data-cell-state`) is one property_identifier whose
1960
+ // text merely contains `cell`; the ACCOUNT counts that line as
1961
+ // other-text, so usages must list it (the #350 equality).
1962
+ return node.text === name;
1958
1963
  } catch (e) {
1959
1964
  return false;
1960
1965
  }
@@ -2524,6 +2529,15 @@ class ProjectIndex {
2524
2529
  saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
2525
2530
 
2526
2531
  /** Load index from cache file */
2532
+ /**
2533
+ * Build under the cross-process build lock (fix #354) and save the cache.
2534
+ * Returns { built, waited }; when another process built meanwhile and its
2535
+ * cache is fresh, that cache is loaded instead of rebuilding.
2536
+ */
2537
+ buildCached(buildOpts = {}, options = {}) {
2538
+ return indexCache.buildWithLock(this, buildOpts, options);
2539
+ }
2540
+
2527
2541
  loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
2528
2542
 
2529
2543
  /** Persist the bounded, content-hash-keyed usage-query cache. */
package/core/search.js CHANGED
@@ -84,7 +84,7 @@ function appendTextComplements(index, {
84
84
  // the AST scan classified and then deliberately dropped (Rust enum
85
85
  // variants against a struct pin, #234) is code, not text: skip it.
86
86
  if (!commentOrString &&
87
- index.isIdentifierAtPosition(content, lineNum, match.index, filePath)) continue;
87
+ index.isIdentifierAtPosition(content, lineNum, match.index, filePath, name)) continue;
88
88
  const usage = {
89
89
  file: filePath,
90
90
  relativePath: fileEntry.relativePath,
package/core/shared.js CHANGED
@@ -126,7 +126,7 @@ function addTestExclusions(exclude) {
126
126
  */
127
127
  function literalNameRegex(name, flags = '') {
128
128
  return new RegExp(
129
- `(?<![\\p{L}\\p{N}_$])${escapeRegExp(name)}(?![\\p{L}\\p{N}_$])`,
129
+ `(?<![\\p{L}\\p{N}_])${escapeRegExp(name)}(?![\\p{L}\\p{N}_])`,
130
130
  flags.includes('u') ? flags : flags + 'u');
131
131
  }
132
132
 
package/languages/java.js CHANGED
@@ -1502,14 +1502,34 @@ function findCallsInCode(code, parser) {
1502
1502
  const valueNode = receiverNode.childForFieldName('value');
1503
1503
  if (valueNode?.type === 'identifier') castReceiverName = valueNode.text;
1504
1504
  }
1505
- const receiver = castReceiverName ||
1505
+ let receiver = castReceiverName ||
1506
1506
  ((receiverNode?.type === 'identifier' || receiverNode?.type === 'this')
1507
1507
  ? receiverNode.text : undefined);
1508
+ // fix #353: `beta.Helper.widget()` — a lowercase dotted root
1509
+ // under a capitalized member is a PACKAGE-qualified type
1510
+ // (Java packages are lowercase by convention, types
1511
+ // capitalized); the receiver is the type and the package is
1512
+ // its qualifier, so same-name static methods in two packages
1513
+ // resolve by the qualifier that is right there in the call.
1514
+ let packageQualifier;
1515
+ if (!receiver && receiverNode?.type === 'field_access') {
1516
+ const rootNode = receiverNode.childForFieldName('object');
1517
+ const fldNode = receiverNode.childForFieldName('field');
1518
+ const rootText = rootNode?.text || '';
1519
+ const rootHead = rootText.split('.')[0];
1520
+ if (fldNode?.type === 'identifier' && /^[A-Z]/.test(fldNode.text) &&
1521
+ /^[a-z_][\w]*(\.[a-z_][\w]*)*$/.test(rootText) &&
1522
+ !getReceiverType(rootHead) && !isDeclaredLocal(rootHead) &&
1523
+ !hasEnclosingField(node, rootHead)) {
1524
+ receiver = fldNode.text;
1525
+ packageQualifier = rootText;
1526
+ }
1527
+ }
1508
1528
  const receiverType = castReceiverType ||
1509
1529
  ((receiver && receiver !== 'this') ? getReceiverType(receiver) : undefined);
1510
- const receiverTypeQualifier = !castReceiverType && receiver
1511
- ? getReceiverTypeQualifier(receiver) : undefined;
1512
- const receiverIsTypeQualified = !!(receiverNode?.type === 'identifier' &&
1530
+ const receiverTypeQualifier = packageQualifier ||
1531
+ (!castReceiverType && receiver ? getReceiverTypeQualifier(receiver) : undefined);
1532
+ const receiverIsTypeQualified = !!((receiverNode?.type === 'identifier' || packageQualifier) &&
1513
1533
  receiver && /^[A-Z]/.test(receiver) && !receiverType &&
1514
1534
  !isDeclaredLocal(receiver) && !hasEnclosingField(node, receiver));
1515
1535
  // fix #202: one-hop declared-field receivers —
package/languages/rust.js CHANGED
@@ -3152,6 +3152,14 @@ function findImportsInCode(code, parser) {
3152
3152
  if (pathNode && aliasNode) {
3153
3153
  addLeaf(joinUsePath(prefix, pathNode.text), aliasNode.text,
3154
3154
  'use', false, line);
3155
+ // fix #353: `use alpha::widget as renamed; renamed()` — the
3156
+ // alias pairing feeds findCallers' import-rename surface
3157
+ // (calledAs), exactly like Python/JS `import x as y`.
3158
+ const original = String(pathNode.text).split('::').pop();
3159
+ if (original && original !== aliasNode.text && original !== 'self') {
3160
+ if (!imports.aliases) imports.aliases = [];
3161
+ imports.aliases.push({ original, local: aliasNode.text });
3162
+ }
3155
3163
  }
3156
3164
  return;
3157
3165
  }
package/mcp/server.js CHANGED
@@ -71,8 +71,13 @@ function getIndex(projectDir, options) {
71
71
  // Disk cache is fresh (skip when maxFiles is set — cached index may have different file count)
72
72
  } else {
73
73
  buildOpts.forceRebuild = !!loaded;
74
- index.build(null, buildOpts);
75
- if (!maxFiles) index.saveCache(); // Don't pollute disk cache with partial indexes
74
+ if (maxFiles) {
75
+ index.build(null, buildOpts); // Don't pollute disk cache with partial indexes
76
+ } else {
77
+ // Cross-process build lock (fix #354): a CLI or another MCP
78
+ // server building the same repo at the same moment shares one build
79
+ index.buildCached(buildOpts);
80
+ }
76
81
  }
77
82
 
78
83
  // LRU eviction
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.3.2",
3
+ "version": "5.3.4",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
6
6
  "main": "index.js",