ucn 5.3.1 → 5.3.2

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,17 @@ 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: `hit` never matches inside `hitΔ`.
165
+
166
+ `endpoints` recognizes client receivers by evidence (a receiver typed to an
167
+ HTTP client class, or bound to a pytest fixture that constructs one), not only
168
+ by name. Request-shaped calls with a path literal on an unrecognized receiver
169
+ are listed under `Possible client requests` (JSON `uncertainRequests`),
170
+ counted in `meta`, never in the inventory. `search` treats its term literally by default; pass `--regex` only
163
171
  when regular-expression semantics are intended. Ordinary regex patterns run
164
172
  through an RE2-compatible linear-time engine; unsafe nested repetition is
165
173
  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 = 213;
701
703
  const USAGE_CACHE_FILE = 'usage-results.json';
702
704
 
703
705
  /**
package/core/callers.js CHANGED
@@ -2802,7 +2802,9 @@ function findCallers(index, name, options = {}) {
2802
2802
  // source name from DIFFERENT modules — the record's local
2803
2803
  // alias (call.name) picks its own binding; source-name
2804
2804
  // matching alone over-follows into the other module.
2805
- if (call.resolvedName && nameBindings.some(b => b.alias)) {
2805
+ // Python records carry the alias as `calledAs` rather
2806
+ // than resolvedName; both name the paired binding.
2807
+ if ((call.resolvedName || calledAs) && nameBindings.some(b => b.alias)) {
2806
2808
  const paired = nameBindings.filter(b => b.alias === call.name);
2807
2809
  if (paired.length > 0) nameBindings = paired;
2808
2810
  }
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,25 @@ 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) {
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
+ return !!node && /identifier|^name$|^word$/.test(node.type);
1958
+ } catch (e) {
1959
+ return false;
1960
+ }
1961
+ }
1962
+
1944
1963
  /**
1945
1964
  * Check if a position in code is inside a comment or string using AST
1946
1965
  * @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)) 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,
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.3.1",
3
+ "version": "5.3.2",
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",