ucn 5.3.1 → 5.3.3

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.
@@ -157,9 +157,25 @@ definition, and `--raw` when the next step is an edit.
157
157
 
158
158
  Treat `deadcode` as a candidate generator. Before deletion, inspect `usages`, `impact`, `entrypoints`, `api`, and `repo --sections=health --deep`; then corroborate with the compiler/type checker and tests. Computed dispatch such as `handlers[key]()` is a reported blind spot; registry members reached by a modeled computed receiver are withheld. Statically named reflection such as `getattr(obj, "run")` is positive liveness evidence, so every matching member spelling is withheld; recognized dynamic reflection is counted and warned because it cannot be attributed to one member. Unknown decorators/annotations and member-assigned event handlers are also withheld by default because they can be the registration itself. All remaining candidates are still review-only. Never delete solely from `deadcode` or an observed-text-zero result.
159
159
 
160
- `usages` includes comment/string/docstring occurrences in an `OTHER TEXT` section unless
161
- `--code-only` is set. This is a literal-name inventory, not exact target
162
- binding. `search` treats its term literally by default; pass `--regex` only
160
+ `usages` includes comment/string/docstring occurrences and non-code text
161
+ (JSX children, HTML markup and attributes) in an `OTHER TEXT` section unless
162
+ `--code-only` is set, so it lists every line the `ACCOUNT` counts. This is a
163
+ literal-name inventory, not exact target binding. Identifier boundaries are
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.
173
+
174
+ `endpoints` recognizes client receivers by evidence (a receiver typed to an
175
+ HTTP client class, or bound to a pytest fixture that constructs one), not only
176
+ by name. Request-shaped calls with a path literal on an unrecognized receiver
177
+ are listed under `Possible client requests` (JSON `uncertainRequests`),
178
+ counted in `meta`, never in the inventory. `search` treats its term literally by default; pass `--regex` only
163
179
  when regular-expression semantics are intended. Ordinary regex patterns run
164
180
  through an RE2-compatible linear-time engine; unsafe nested repetition is
165
181
  rejected, and unsupported advanced syntax should be handed to ripgrep.
package/core/account.js CHANGED
@@ -52,7 +52,7 @@
52
52
 
53
53
  const fs = require('fs');
54
54
  const path = require('path');
55
- const { escapeRegExp, codeUnitCompare } = require('./shared');
55
+ const { codeUnitCompare, literalNameRegex } = require('./shared');
56
56
 
57
57
  // Unsupported-language site listings are capped so a Rails-sized repo cannot
58
58
  // flood the account object; the counts always cover the full set.
@@ -83,7 +83,7 @@ function computeGroundSet(index, name) {
83
83
  index._groundSetCache.set(name, cached);
84
84
  return cached.result;
85
85
  }
86
- const wordRe = new RegExp('\\b' + escapeRegExp(name) + '\\b');
86
+ const wordRe = literalNameRegex(name);
87
87
  const perFile = new Map();
88
88
  let total = 0;
89
89
  let fileCount = 0;
@@ -158,7 +158,7 @@ function scanFailedFiles(index, name) {
158
158
  if (!index.failedFiles || index.failedFiles.size === 0) {
159
159
  return { unparsed, unreadableFiles };
160
160
  }
161
- const wordRe = new RegExp('\\b' + escapeRegExp(name) + '\\b');
161
+ const wordRe = literalNameRegex(name);
162
162
  for (const failedPath of index.failedFiles) {
163
163
  if (index.files.has(failedPath)) continue;
164
164
  let content;
@@ -204,7 +204,7 @@ function scanUnsupportedFiles(index, name, opts = {}) {
204
204
  if (!Array.isArray(index.unsupportedFiles) || index.unsupportedFiles.length === 0) {
205
205
  return unsupported;
206
206
  }
207
- const wordRe = new RegExp('\\b' + escapeRegExp(name) + '\\b');
207
+ const wordRe = literalNameRegex(name);
208
208
  const languageCounts = new Map();
209
209
  for (const skipped of index.unsupportedFiles) {
210
210
  const absPath = path.join(index.root, skipped.relativePath);
package/core/bridge.js CHANGED
@@ -1066,11 +1066,60 @@ function extractNextjsRoutes(index) {
1066
1066
  * Detect HTTP client requests across the project.
1067
1067
  * Cached on `index._endpointsCache.clientRequests`.
1068
1068
  */
1069
+ // Python HTTP client types whose instances are receivers of request calls.
1070
+ // A receiver typed to one of these (with-binding, constructor assignment) or
1071
+ // bound to a pytest fixture that constructs one is a client by evidence, not
1072
+ // by name (fix #349: 145 of 625 real `tc.get("/api/...")` sites on one repo
1073
+ // were invisible because the receiver was not literally named `client`).
1074
+ const PY_CLIENT_TYPES = new Set([
1075
+ 'TestClient', 'Client', 'AsyncClient', 'Session', 'FlaskClient',
1076
+ 'ClientSession', 'AsyncSession', 'HTTPConnection', 'HTTPSConnection',
1077
+ ]);
1078
+ const PY_CLIENT_FACTORIES = new Set(['test_client', 'Session', 'Client', 'AsyncClient']);
1079
+ const PY_REQUEST_METHODS = /^(get|post|put|delete|patch|options|head|request)$/;
1080
+
1081
+ function _pythonClientFixtures(index) {
1082
+ if (index._endpointsCache?.pyClientFixtures) return index._endpointsCache.pyClientFixtures;
1083
+ const fixtures = new Set();
1084
+ for (const [filePath, fileEntry] of index.files) {
1085
+ if (fileEntry.language !== 'python') continue;
1086
+ const fixtureDefs = (fileEntry.symbols || []).filter(s =>
1087
+ (s.decorators || []).some(d => /(^|\.)fixture$/.test(String(d))));
1088
+ if (fixtureDefs.length === 0) continue;
1089
+ const calls = getCachedCalls(index, filePath) || [];
1090
+ for (const def of fixtureDefs) {
1091
+ const constructsClient = calls.some(c =>
1092
+ c.enclosingFunction?.name === def.name &&
1093
+ c.enclosingFunction?.startLine === def.startLine &&
1094
+ (PY_CLIENT_TYPES.has(c.name) || (c.isMethod && PY_CLIENT_FACTORIES.has(c.name))));
1095
+ if (constructsClient) fixtures.add(def.name);
1096
+ }
1097
+ }
1098
+ if (!index._endpointsCache) index._endpointsCache = {};
1099
+ index._endpointsCache.pyClientFixtures = fixtures;
1100
+ return fixtures;
1101
+ }
1102
+
1103
+ function _pythonClientReceiver(index, fileEntry, call, fixtures) {
1104
+ if (!call.isMethod || !call.receiver || !PY_REQUEST_METHODS.test(call.name)) return null;
1105
+ if (call.receiverType && PY_CLIENT_TYPES.has(String(call.receiverType).split('.').pop())) {
1106
+ return 'python-client';
1107
+ }
1108
+ if (fixtures.size === 0 || !fixtures.has(call.receiver)) return null;
1109
+ const fn = call.enclosingFunction;
1110
+ if (!fn) return null;
1111
+ const sym = (fileEntry.symbols || []).find(s => s.name === fn.name && s.startLine === fn.startLine);
1112
+ const params = String(sym?.params || '').split(',').map(p => p.trim().split(/[:=]/)[0].trim());
1113
+ return params.includes(call.receiver) ? 'pytest-client-fixture' : null;
1114
+ }
1115
+
1069
1116
  function extractClientRequests(index) {
1070
1117
  if (index._endpointsCache && index._endpointsCache.clientRequests) {
1071
1118
  return index._endpointsCache.clientRequests;
1072
1119
  }
1073
1120
  const requests = [];
1121
+ const uncertain = [];
1122
+ const pyFixtures = _pythonClientFixtures(index);
1074
1123
 
1075
1124
  for (const [filePath, fileEntry] of index.files) {
1076
1125
  const lang = fileEntry.language;
@@ -1079,8 +1128,30 @@ function extractClientRequests(index) {
1079
1128
 
1080
1129
  for (const call of calls) {
1081
1130
  if (!call.firstStringArg) continue;
1082
- const r = matchClientRequest(call, lang, calls);
1083
- if (!r) continue;
1131
+ let r = matchClientRequest(call, lang, calls);
1132
+ if (!r && lang === 'python') {
1133
+ const framework = _pythonClientReceiver(index, fileEntry, call, pyFixtures);
1134
+ if (framework) {
1135
+ r = { method: call.name.toUpperCase() === 'REQUEST' ? 'ALL' : call.name.toUpperCase(),
1136
+ framework, methodInferred: call.name === 'request' };
1137
+ }
1138
+ }
1139
+ if (!r) {
1140
+ // Visible uncertainty: request-shaped call on an unrecognized
1141
+ // receiver with a path-shaped literal. Listed, never counted.
1142
+ const conf = CLIENT_PATTERNS[lang];
1143
+ const pathShaped = call.firstStringArg.startsWith('/') || call.firstStringArg.includes('://');
1144
+ if (conf && call.isMethod && call.receiver && pathShaped &&
1145
+ conf.receivers.some(p => p.methodPattern.test(call.name))) {
1146
+ uncertain.push({
1147
+ receiver: call.receiver, method: call.name, path: call.firstStringArg,
1148
+ file: fileEntry.relativePath || filePath, absoluteFile: filePath, line: call.line,
1149
+ callerName: call.enclosingFunction?.name || '<top-level>',
1150
+ reason: 'receiver-unrecognized',
1151
+ });
1152
+ }
1153
+ continue;
1154
+ }
1084
1155
 
1085
1156
  // Python's common `session.get("key")` / `s.get("key")`
1086
1157
  // dictionary and ORM idioms are not HTTP requests. Without
@@ -1114,6 +1185,9 @@ function extractClientRequests(index) {
1114
1185
  }
1115
1186
 
1116
1187
  // Stable sort
1188
+ uncertain.sort((a, b) => a.file !== b.file ? codeUnitCompare(a.file, b.file) : a.line - b.line);
1189
+ if (!index._endpointsCache) index._endpointsCache = {};
1190
+ index._endpointsCache.uncertainRequests = uncertain;
1117
1191
  requests.sort((a, b) => {
1118
1192
  if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
1119
1193
  if (a.line !== b.line) return a.line - b.line;
@@ -1418,6 +1492,13 @@ function endpoints(index, options = {}) {
1418
1492
 
1419
1493
  let routes = opts.clientOnly ? [] : extractServerRoutes(index);
1420
1494
  let requests = (opts.serverOnly ? [] : extractClientRequests(index));
1495
+ let uncertainRequests = opts.serverOnly ? [] : (index._endpointsCache?.uncertainRequests || []);
1496
+ if (uncertainRequests.length > 0) {
1497
+ // A server route registration (`@app.get("/x")`, `router.get("/x", h)`)
1498
+ // is request-shaped too; the route inventory already owns those lines.
1499
+ const routeLines = new Set(extractServerRoutes(index).map(r => `${r.absoluteFile}:${r.line}`));
1500
+ uncertainRequests = uncertainRequests.filter(r => !routeLines.has(`${r.absoluteFile}:${r.line}`));
1501
+ }
1421
1502
 
1422
1503
  // Apply filters
1423
1504
  if (opts.method) {
@@ -1427,6 +1508,10 @@ function endpoints(index, options = {}) {
1427
1508
  if (opts.prefix) {
1428
1509
  routes = routes.filter(r => r.path.startsWith(opts.prefix) || r.normalizedPath.startsWith(opts.prefix));
1429
1510
  requests = requests.filter(r => r.path.startsWith(opts.prefix) || r.normalizedPath.startsWith(opts.prefix));
1511
+ uncertainRequests = uncertainRequests.filter(r => r.path.startsWith(opts.prefix));
1512
+ }
1513
+ if (opts.method) {
1514
+ uncertainRequests = uncertainRequests.filter(r => r.method.toUpperCase() === opts.method || r.method === 'request');
1430
1515
  }
1431
1516
 
1432
1517
  let bridges = opts.bridge ? bridgeEndpoints(index) : [];
@@ -1472,12 +1557,14 @@ function endpoints(index, options = {}) {
1472
1557
  : 'incomplete-endpoint-inventory',
1473
1558
  routes,
1474
1559
  requests,
1560
+ uncertainRequests,
1475
1561
  bridges,
1476
1562
  unmatchedRoutes,
1477
1563
  unmatchedRequests,
1478
1564
  meta: {
1479
1565
  totalRoutes: routes.length,
1480
1566
  totalRequests: requests.length,
1567
+ uncertainRequests: uncertainRequests.length,
1481
1568
  totalBridges: bridges.length,
1482
1569
  unmatchedRoutes: unmatchedRoutes.length,
1483
1570
  unmatchedRequests: unmatchedRequests.length,
package/core/cache.js CHANGED
@@ -697,7 +697,9 @@ function clearAllCaches() {
697
697
  // default/type imports and inline type re-exports preserve execution timing.
698
698
  // Python TYPE_CHECKING guards require typing ownership and no rebinding.
699
699
  // v212 (fix #342): extendsGraph/extendedByGraph no longer persisted (rebuilt on load).
700
- const CACHE_FORMAT_VERSION = 212;
700
+ // v213 (fix #348): Python from-import records carry per-name `renames` so
701
+ // import bindings pair each alias with its own module.
702
+ const CACHE_FORMAT_VERSION = 214;
701
703
  const USAGE_CACHE_FILE = 'usage-results.json';
702
704
 
703
705
  /**
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 &&
@@ -2802,10 +2814,22 @@ function findCallers(index, name, options = {}) {
2802
2814
  // source name from DIFFERENT modules — the record's local
2803
2815
  // alias (call.name) picks its own binding; source-name
2804
2816
  // matching alone over-follows into the other module.
2805
- if (call.resolvedName && nameBindings.some(b => b.alias)) {
2817
+ // Python records carry the alias as `calledAs` rather
2818
+ // than resolvedName; both name the paired binding.
2819
+ if ((call.resolvedName || calledAs) && nameBindings.some(b => b.alias)) {
2806
2820
  const paired = nameBindings.filter(b => b.alias === call.name);
2807
2821
  if (paired.length > 0) nameBindings = paired;
2808
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);
2809
2833
  const tFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
2810
2834
  // fix #215 (rich-measured: 225 builtin `print(...)` calls
2811
2835
  // confirmed against rich's def via file-level import edges):
@@ -4073,12 +4097,17 @@ function findCallers(index, name, options = {}) {
4073
4097
  if (call.isPathCall && receiverName) {
4074
4098
  receiverName = String(receiverName).split('::').pop();
4075
4099
  }
4100
+ let aliasResolvedFile = null;
4076
4101
  if (receiverName && !tTypes.has(receiverName)) {
4077
4102
  for (const im of (fileEntry.importBindings || [])) {
4078
4103
  if (im.name !== receiverName) continue;
4079
- 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();
4080
4107
  if (orig && orig !== receiverName && tTypes.has(orig)) {
4081
4108
  receiverName = orig;
4109
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[im.module];
4110
+ if (rel) aliasResolvedFile = path.join(index.root, rel);
4082
4111
  break;
4083
4112
  }
4084
4113
  }
@@ -4106,7 +4135,14 @@ function findCallers(index, name, options = {}) {
4106
4135
  // name fallback above handles multi-definition names; this
4107
4136
  // covers single-definition targets that skip it.
4108
4137
  if (typeQualifiedReceiver) {
4109
- 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);
4110
4146
  if (identity === 'other') {
4111
4147
  recordExcluded(filePath, call.line, 'path-type-mismatch');
4112
4148
  continue;
@@ -5342,6 +5378,12 @@ function findCallees(index, definition, options = {}) {
5342
5378
  for (let call of calls) {
5343
5379
  siteOrdinal++;
5344
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
+ }
5345
5387
  if (language === 'go' && call.isMethod &&
5346
5388
  !call.receiverType && call.receiverIndexField) {
5347
5389
  const indexedType = _goIndexedReceiverType(index, def.file, call);
@@ -9851,6 +9893,42 @@ function _projectTopLevelNames(index) {
9851
9893
  return names;
9852
9894
  }
9853
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
+
9854
9932
  /**
9855
9933
  * Is an UNRESOLVED module specifier a resolver gap rather than externality
9856
9934
  * evidence? (fix #337b) Relative specifiers and first segments naming a
@@ -9978,6 +10056,32 @@ function _isGenericParamReceiverType(index, filePath, line, typeName) {
9978
10056
  return _isEnclosingGenericParam(index, filePath, line, typeName);
9979
10057
  }
9980
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
+
9981
10085
  /**
9982
10086
  * Java same-package check across Maven/Gradle source roots (fix #246):
9983
10087
  * src/main/java/<pkg> and src/test/java/<pkg> hold the SAME package —
@@ -10017,6 +10121,16 @@ function _resolveReceiverTypeIdentity(index, filePath, knownType, targetDefs, li
10017
10121
  // cannot resolve it. That is not exclusion evidence.
10018
10122
  return 'unknown';
10019
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
+ }
10020
10134
  if (language === 'java' && line == null) {
10021
10135
  // Return-flow annotations are interpreted in the PRODUCER definition's
10022
10136
  // file. That origin is stronger than same-package lookup and is
@@ -10963,6 +11077,23 @@ function _calleeStructuralBindingRoute(index, fileEntry, call, language, binding
10963
11077
  let sawProjectish = false;
10964
11078
  let sawUnknown = false;
10965
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
+ }
10966
11097
  const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[binding.module];
10967
11098
  if (!rel) {
10968
11099
  if (_unresolvedModuleIsGap(index, binding.module, binding)) {
@@ -11720,6 +11851,42 @@ function _nonCallableFieldMember(index, typeName, name, language) {
11720
11851
  * (#215): the class defined in this file or a file binding of the name —
11721
11852
  * an unbound capitalized receiver may be a parameter or local.
11722
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
+
11723
11890
  /**
11724
11891
  * Namespace/module-container resolution (fix #254, W8 BUG-4 — verify's
11725
11892
  * BUG-BX rule brought into the engine, range-based): `Utils.slug()` where a
@@ -11833,7 +12000,8 @@ function _calleeTypeQualifiedReceiver(index, def, fileEntry, call, language) {
11833
12000
  if (typeDefs.length === 0) {
11834
12001
  for (const im of (fileEntry?.importBindings || [])) {
11835
12002
  if (im.name !== receiver) continue;
11836
- 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();
11837
12005
  if (orig && orig !== receiver && typeKindsOf(orig).length > 0) {
11838
12006
  receiver = orig;
11839
12007
  typeDefs = typeKindsOf(orig);
@@ -11875,9 +12043,30 @@ function _calleeTypeQualifiedReceiver(index, def, fileEntry, call, language) {
11875
12043
  _normalizedAliasBase(index, d)));
11876
12044
  if (bases.size === 1) candidateTypes.push(bases.values().next().value);
11877
12045
  }
11878
- const symbols = index.symbols.get(call.name) || [];
12046
+ const allSymbols = index.symbols.get(call.name) || [];
11879
12047
  const isCallable = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
11880
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;
11881
12070
  if (language === 'java' || language === 'csharp') {
11882
12071
  // Class-qualified Java/C# calls see the compiler member group on the
11883
12072
  // qualifier. The helper handles inherited slots and C# name hiding.
package/core/execute.js CHANGED
@@ -1836,6 +1836,7 @@ const HANDLERS = {
1836
1836
  result.meta = {
1837
1837
  totalRoutes: result.routes.length,
1838
1838
  totalRequests: result.requests.length,
1839
+ uncertainRequests: (result.uncertainRequests || []).length,
1839
1840
  totalBridges: result.bridges.length,
1840
1841
  unmatchedRoutes: result.unmatchedRoutes.length,
1841
1842
  unmatchedRequests: result.unmatchedRequests.length,
@@ -26,7 +26,7 @@ function formatEndpoints(result, options = {}) {
26
26
  const showBridge = options.bridge;
27
27
 
28
28
  if (!showBridge) {
29
- return formatRoutesAndRequests(routes, requests, meta, options, result.advisory);
29
+ return formatRoutesAndRequests(routes, requests, meta, options, result.advisory, result);
30
30
  }
31
31
  return formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, options, result.advisory);
32
32
  }
@@ -48,7 +48,20 @@ function uniqueMatchPercent(bridges, totalRequests) {
48
48
  return Math.min(100, Math.max(0, pct));
49
49
  }
50
50
 
51
- function formatRoutesAndRequests(routes, requests, meta, options, advisory = null) {
51
+ function formatUncertainRequests(result, options) {
52
+ const list = result?.uncertainRequests || [];
53
+ if (list.length === 0) return [];
54
+ const lines = [];
55
+ lines.push(`Possible client requests (${list.length}) — request-shaped call with a path literal, receiver not recognized as an HTTP client:`);
56
+ const cap = options.all ? Infinity : 10;
57
+ for (const r of list.slice(0, cap)) {
58
+ lines.push(` ${r.file}:${r.line} ${r.receiver}.${r.method}(${JSON.stringify(r.path)}) in ${r.callerName}`);
59
+ }
60
+ if (list.length > cap) lines.push(` (+${list.length - cap} more — use --all)`);
61
+ return lines;
62
+ }
63
+
64
+ function formatRoutesAndRequests(routes, requests, meta, options, advisory = null, result = null) {
52
65
  const lines = [];
53
66
  const showServer = !options.clientOnly;
54
67
  const showClient = !options.serverOnly;
@@ -109,6 +122,10 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
109
122
  }
110
123
  }
111
124
 
125
+ if (showClient) {
126
+ const uncertain = formatUncertainRequests(result, options);
127
+ if (uncertain.length > 0) lines.push('', ...uncertain);
128
+ }
112
129
  const routeAdvisory = advisoryLine(advisory);
113
130
  if (routeAdvisory) lines.push('', routeAdvisory);
114
131
  return lines.join('\n').trimEnd();
@@ -230,6 +247,10 @@ function formatEndpointsJson(result, options = {}) {
230
247
  data: {
231
248
  routes: routes.map(trimRoute),
232
249
  requests: requests.map(trimReq),
250
+ uncertainRequests: (result.uncertainRequests || []).map(r => ({
251
+ receiver: r.receiver, method: r.method, path: r.path,
252
+ file: r.file, line: r.line, callerName: r.callerName, reason: r.reason,
253
+ })),
233
254
  // In unmatched-only mode, the matched bridges array is suppressed
234
255
  // — consumers that want both should not pass --unmatched.
235
256
  bridges: unmatchedOnly ? [] : bridges.map(trimBridge),
package/core/project.js CHANGED
@@ -17,7 +17,7 @@ const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits, PA
17
17
  const { validateFileIR } = require('./ir');
18
18
  const { createFileEntryFromIR, populateFileEntryFromIR } = require('./index-ir');
19
19
  const { getTokenTypeAtPosition } = require('../languages/utils');
20
- const { escapeRegExp, NON_CALLABLE_TYPES, codeUnitCompare } = require('./shared');
20
+ const { escapeRegExp, NON_CALLABLE_TYPES, codeUnitCompare, literalNameRegex } = require('./shared');
21
21
  const stacktrace = require('./stacktrace');
22
22
  const indexCache = require('./cache');
23
23
  const deadcodeModule = require('./deadcode');
@@ -1523,7 +1523,7 @@ class ProjectIndex {
1523
1523
 
1524
1524
  // Detailed path: full AST-based counting (original algorithm)
1525
1525
  // Note: no 'g' flag - we only need to test for presence per line
1526
- const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
1526
+ const regex = literalNameRegex(name);
1527
1527
 
1528
1528
  // Get files that could reference this symbol:
1529
1529
  // 1. The file where it's defined
@@ -1941,6 +1941,30 @@ class ProjectIndex {
1941
1941
  }
1942
1942
  }
1943
1943
 
1944
+ /**
1945
+ * True when the AST node at (line, column) is an identifier token — code
1946
+ * the usage scan saw (and may have deliberately dropped), as opposed to
1947
+ * JSX children, HTML markup, or other non-code text (fix #350).
1948
+ */
1949
+ isIdentifierAtPosition(content, lineNum, column, filePath, name) {
1950
+ const language = detectLanguage(filePath, this.root);
1951
+ if (!language) return false;
1952
+ try {
1953
+ const tree = this._getParsedTree(filePath, content, language) ||
1954
+ safeParse(getParser(language), content);
1955
+ if (!tree) return false;
1956
+ const node = tree.rootNode.descendantForPosition({ row: lineNum - 1, column });
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;
1963
+ } catch (e) {
1964
+ return false;
1965
+ }
1966
+ }
1967
+
1944
1968
  /**
1945
1969
  * Check if a position in code is inside a comment or string using AST
1946
1970
  * @param {string} content - File content
package/core/search.js CHANGED
@@ -8,7 +8,7 @@
8
8
  'use strict';
9
9
 
10
10
  const path = require('path');
11
- const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames, CALLABLE_SYMBOL_KINDS } = require('./shared');
11
+ const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames, CALLABLE_SYMBOL_KINDS, literalNameRegex } = require('./shared');
12
12
  const { isTestFile } = require('./discovery');
13
13
  const { detectLanguage, getParser, getLanguageAdapter, langTraits } = require('../languages');
14
14
  const { getCachedCalls } = require('./callers');
@@ -49,11 +49,6 @@ function matchesSubstring(text, pattern, caseSensitive) {
49
49
  return text.toLowerCase().includes(pattern.toLowerCase());
50
50
  }
51
51
 
52
- function literalNameRegex(name) {
53
- return new RegExp(
54
- `(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`,
55
- );
56
- }
57
52
 
58
53
  /**
59
54
  * Complete the AST-classified usage inventory with literal-name lines that
@@ -82,14 +77,21 @@ function appendTextComplements(index, {
82
77
  const lineNum = idx + 1;
83
78
  const commentOrString = index.isCommentOrStringAtPosition(
84
79
  content, lineNum, match.index, filePath);
85
- if (!commentOrString) continue;
80
+ // A literal match that no AST identifier record claimed and that is
81
+ // not inside a comment or string is non-code text: JSX children,
82
+ // HTML markup and attributes, regex bodies. The ACCOUNT counts these
83
+ // lines as other-text; the escape hatch must list them too. A line
84
+ // the AST scan classified and then deliberately dropped (Rust enum
85
+ // variants against a struct pin, #234) is code, not text: skip it.
86
+ if (!commentOrString &&
87
+ index.isIdentifierAtPosition(content, lineNum, match.index, filePath, name)) continue;
86
88
  const usage = {
87
89
  file: filePath,
88
90
  relativePath: fileEntry.relativePath,
89
91
  line: lineNum,
90
92
  content: line,
91
93
  usageType: 'text',
92
- textKind: 'comment-or-string',
94
+ textKind: commentOrString ? 'comment-or-string' : 'markup-or-text',
93
95
  isDefinition: false,
94
96
  };
95
97
  if (context > 0) {
@@ -397,7 +399,7 @@ function usages(index, name, options = {}) {
397
399
  }
398
400
 
399
401
  // Fallback to regex-based detection
400
- const regex = new RegExp('\\b' + escapeRegExp(name) + '\\b');
402
+ const regex = literalNameRegex(name);
401
403
  lines.forEach((line, idx) => {
402
404
  const lineNum = idx + 1;
403
405
 
package/core/shared.js CHANGED
@@ -118,6 +118,18 @@ function addTestExclusions(exclude) {
118
118
  /**
119
119
  * Escape special regex characters
120
120
  */
121
+ /**
122
+ * Whole-identifier match for a symbol name. `\b` is ASCII-only in JS, so
123
+ * `hit` matched inside `hitΔ` while every compiler and ripgrep treat Δ as an
124
+ * identifier character. Letters, digits, `_` and `$` on either side break a
125
+ * match; anything else (punctuation, whitespace, line edges) is a boundary.
126
+ */
127
+ function literalNameRegex(name, flags = '') {
128
+ return new RegExp(
129
+ `(?<![\\p{L}\\p{N}_])${escapeRegExp(name)}(?![\\p{L}\\p{N}_])`,
130
+ flags.includes('u') ? flags : flags + 'u');
131
+ }
132
+
121
133
  function escapeRegExp(text) {
122
134
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
123
135
  }
@@ -463,7 +475,7 @@ function maskBlockComments(content, language) {
463
475
  return out.join('');
464
476
  }
465
477
 
466
- module.exports = {
478
+ module.exports = { literalNameRegex,
467
479
  pickBestDefinition,
468
480
  addTestExclusions,
469
481
  escapeRegExp,
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 —
@@ -3140,6 +3140,7 @@ function findImportsInCode(code, parser) {
3140
3140
  const deferral = importDeferral(node);
3141
3141
  let modulePath = '';
3142
3142
  const names = [];
3143
+ const renames = [];
3143
3144
 
3144
3145
  for (let i = 0; i < node.namedChildCount; i++) {
3145
3146
  const child = node.namedChild(i);
@@ -3158,6 +3159,7 @@ function findImportsInCode(code, parser) {
3158
3159
  if (nameNode && aliasNode && aliasNode.text !== nameNode.text) {
3159
3160
  if (!importAliases) importAliases = [];
3160
3161
  importAliases.push({ original: nameNode.text, local: aliasNode.text });
3162
+ renames.push({ original: nameNode.text, local: aliasNode.text });
3161
3163
  }
3162
3164
  } else if (child.type === 'wildcard_import') {
3163
3165
  names.push('*');
@@ -3171,6 +3173,7 @@ function findImportsInCode(code, parser) {
3171
3173
  names,
3172
3174
  type: isRelative ? 'relative' : 'from',
3173
3175
  line,
3176
+ ...(renames.length > 0 && { renames }),
3174
3177
  ...(deferral && { deferred: true, deferredReason: deferral })
3175
3178
  });
3176
3179
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.3.1",
3
+ "version": "5.3.3",
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",