ucn 5.3.0 → 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.
@@ -69,6 +69,16 @@ identity; matching attribute syntax with an unresolved receiver stays
69
69
  unverified. These are change dependencies, not fabricated caller edges, so
70
70
  the caller `ACCOUNT` remains a call-shaped partition.
71
71
 
72
+ When the selected definition is a type, interface, enum, trait, or record,
73
+ `impact` adds a `TYPE REFERENCE SITES` band: annotation and reference sites
74
+ confirmed by an import link to the definition's file (or package scope in
75
+ Go/Java), the rest visible as unverified with a reason. `DEPENDENCY SITES`
76
+ counts them; `CALL SITES` stays call-shaped.
77
+
78
+ Target-less `impact` and `check` diff the working tree against `HEAD` AND
79
+ include untracked, non-ignored source files as whole-file additions, so new
80
+ modules are checked before `git add`. `--staged` keeps its index-only meaning.
81
+
72
82
  An observed-text zero is not semantic zero or safe-delete proof. Numeric evidence values are ordinal ranking weights, not probabilities.
73
83
 
74
84
  When a plain name selects more than one definition, action-oriented commands
@@ -147,9 +157,17 @@ definition, and `--raw` when the next step is an edit.
147
157
 
148
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.
149
159
 
150
- `usages` includes comment/string/docstring occurrences in an `OTHER TEXT` section unless
151
- `--code-only` is set. This is a literal-name inventory, not exact target
152
- 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
153
171
  when regular-expression semantics are intended. Ordinary regex patterns run
154
172
  through an RE2-compatible linear-time engine; unsafe nested repetition is
155
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/analysis.js CHANGED
@@ -980,6 +980,113 @@ function related(index, name, options = {}) {
980
980
  * @param {object} options - { file, className, exclude, top }
981
981
  * @returns {object|null}
982
982
  */
983
+ // Kinds whose dependents are annotation/reference sites rather than calls.
984
+ // Classes and structs stay out: `new X()` / `X{}` are call-shaped and already
985
+ // flow through the caller sweep.
986
+ const TYPE_REFERENCE_KINDS = new Set(['type', 'interface', 'enum', 'trait', 'record']);
987
+
988
+ /**
989
+ * fix #345: tiered annotation-site band for a type-kind definition.
990
+ * Confirmed needs identity evidence: same file as the definition, or an
991
+ * import binding of the name in the referencing file that reaches the
992
+ * definition's file (the #215/#217 scope discipline). Anything else is
993
+ * VISIBLE unverified with a reason. A same-name definition elsewhere is
994
+ * excluded as other-target. Spelling alone never confirms.
995
+ */
996
+ function findTypeReferences(index, name, def, options = {}) {
997
+ if (!def || !TYPE_REFERENCE_KINDS.has(def.type)) return null;
998
+ const { usages } = require('./search');
999
+ const { _importReaches, _sameNominalPackageDir } = require('./callers');
1000
+ const records = usages(index, name, {
1001
+ includeTests: true, codeOnly: true, exclude: options.exclude,
1002
+ });
1003
+ const targetFiles = new Set([def.file]);
1004
+ const sameNameDefs = (index.symbols.get(name) || []).filter(d => d !== def);
1005
+ const confirmed = [];
1006
+ const unverified = [];
1007
+ const excluded = [];
1008
+ for (const u of (Array.isArray(records) ? records : records?.usages || [])) {
1009
+ if (u.isDefinition || u.usageType !== 'reference') continue;
1010
+ const site = {
1011
+ file: u.relativePath, line: u.line,
1012
+ expression: (u.content || '').trim(),
1013
+ };
1014
+ // A same-name type defined in the referencing file owns that file's
1015
+ // bare references (the #215 scope rule): excluded, never confirmed.
1016
+ if (u.file !== def.file && sameNameDefs.some(d => d.file === u.file && TYPE_REFERENCE_KINDS.has(d.type))) {
1017
+ excluded.push({ ...site, reason: 'other-definition' });
1018
+ continue;
1019
+ }
1020
+ if (sameNameDefs.some(d => d.file === u.file && (d.nameLine || d.startLine) === u.line)) {
1021
+ excluded.push({ ...site, reason: 'other-definition' });
1022
+ continue;
1023
+ }
1024
+ if (u.file === def.file) {
1025
+ confirmed.push({ ...site, evidence: 'same-file' });
1026
+ continue;
1027
+ }
1028
+ const fileEntry = index.files.get(u.file);
1029
+ // Directory-scoped packages (Go) and Java packages see sibling files'
1030
+ // types without an import; a same-name def in another package would
1031
+ // have been excluded above only if it shared the line, so require the
1032
+ // pinned def to be the package's own.
1033
+ const packageScoped = fileEntry && (
1034
+ (langTraits(fileEntry.language).packageScope === 'directory' &&
1035
+ path.dirname(u.file) === path.dirname(def.file)) ||
1036
+ (fileEntry.language === 'java' &&
1037
+ _sameNominalPackageDir(path.dirname(def.file), path.dirname(u.file), 'java')));
1038
+ if (packageScoped) {
1039
+ const foreign = sameNameDefs.some(d => TYPE_REFERENCE_KINDS.has(d.type) &&
1040
+ path.dirname(d.file) === path.dirname(u.file));
1041
+ if (foreign) unverified.push({ ...site, reason: 'same-package-ambiguous' });
1042
+ else confirmed.push({ ...site, evidence: 'package-scope' });
1043
+ continue;
1044
+ }
1045
+ const bindings = (fileEntry?.importBindings || []).filter(b =>
1046
+ b.name === name || b.alias === name);
1047
+ if (bindings.length > 0) {
1048
+ const reaches = bindings.some(b => {
1049
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
1050
+ return rel && _importReaches(index, path.join(index.root, rel), targetFiles);
1051
+ });
1052
+ if (reaches) { confirmed.push({ ...site, evidence: 'import' }); continue; }
1053
+ const otherProject = bindings.some(b =>
1054
+ fileEntry.moduleResolved && fileEntry.moduleResolved[b.module]);
1055
+ if (otherProject) { excluded.push({ ...site, reason: 'other-definition-import' }); continue; }
1056
+ unverified.push({ ...site, reason: 'import-unresolved' });
1057
+ continue;
1058
+ }
1059
+ if (fileEntry?.importNames?.includes('*')) {
1060
+ unverified.push({ ...site, reason: 'star-import' });
1061
+ continue;
1062
+ }
1063
+ unverified.push({ ...site, reason: 'no-import-link' });
1064
+ }
1065
+ if (confirmed.length === 0 && unverified.length === 0 && excluded.length === 0) {
1066
+ return { owner: def.type, confirmedCount: 0, unverifiedCount: 0, totalCandidates: 0,
1067
+ byFile: [], unverifiedSites: [], excluded: { total: 0, byReason: {} } };
1068
+ }
1069
+ const bySite = (a, b) => a.file !== b.file ? codeUnitCompare(a.file, b.file) : a.line - b.line;
1070
+ confirmed.sort(bySite); unverified.sort(bySite);
1071
+ const byFile = new Map();
1072
+ for (const site of confirmed) {
1073
+ if (!byFile.has(site.file)) byFile.set(site.file, []);
1074
+ byFile.get(site.file).push(site);
1075
+ }
1076
+ return {
1077
+ owner: def.type,
1078
+ confirmedCount: confirmed.length,
1079
+ unverifiedCount: unverified.length,
1080
+ totalCandidates: confirmed.length + unverified.length,
1081
+ byFile: [...byFile.entries()].map(([file, sites]) => ({ file, count: sites.length, sites })),
1082
+ unverifiedSites: unverified,
1083
+ excluded: {
1084
+ total: excluded.length,
1085
+ byReason: excluded.reduce((out, s) => { out[s.reason] = (out[s.reason] || 0) + 1; return out; }, {}),
1086
+ },
1087
+ };
1088
+ }
1089
+
983
1090
  function impact(index, name, options = {}) {
984
1091
  index._beginOp();
985
1092
  try {
@@ -1270,6 +1377,13 @@ function impact(index, name, options = {}) {
1270
1377
  };
1271
1378
  }
1272
1379
 
1380
+ // fix #345: a type/interface/enum/trait is consumed through annotations,
1381
+ // not calls. Those sites were counted in the ACCOUNT as references and
1382
+ // listed nowhere, so the headline said 0 for a type with dozens of
1383
+ // dependents. Same design as the accessor band: a separate band, never
1384
+ // fake caller edges (the caller oracle and the account stay call-shaped).
1385
+ let typeReferences = findTypeReferences(index, name, def, options);
1386
+
1273
1387
  // Apply top limit if specified (limits total call sites shown)
1274
1388
  const totalBeforeLimit = filteredSites.length;
1275
1389
  if (options.top && options.top > 0 && filteredSites.length > options.top) {
@@ -1315,6 +1429,8 @@ function impact(index, name, options = {}) {
1315
1429
  ...Array.from(byFile.keys()),
1316
1430
  ...(propertyAccesses?.byFile || []).map(group => group.file),
1317
1431
  ...(propertyAccesses?.unverifiedSites || []).map(site => site.file),
1432
+ ...(typeReferences?.byFile || []).map(group => group.file),
1433
+ ...(typeReferences?.unverifiedSites || []).map(site => site.file),
1318
1434
  ]);
1319
1435
 
1320
1436
  return {
@@ -1332,6 +1448,11 @@ function impact(index, name, options = {}) {
1332
1448
  totalDependencySites: totalBeforeLimit + propertyAccesses.confirmedCount,
1333
1449
  affectedFiles: affectedFiles.size,
1334
1450
  }),
1451
+ ...(typeReferences && {
1452
+ typeReferences,
1453
+ totalDependencySites: totalBeforeLimit + typeReferences.confirmedCount,
1454
+ affectedFiles: affectedFiles.size,
1455
+ }),
1335
1456
  account: impactAccount,
1336
1457
  hasEntrypoints: !!impactReachable && impactReachable.size > 0,
1337
1458
  callerHistogram,
@@ -1876,19 +1997,6 @@ function diffImpact(index, options = {}) {
1876
1997
  }
1877
1998
  }
1878
1999
 
1879
- if (!diffText || !diffText.trim()) {
1880
- return {
1881
- base: staged ? '(staged)' : base,
1882
- changedPaths: 0,
1883
- nonSourcePaths: 0,
1884
- functions: [],
1885
- moduleLevelChanges: [],
1886
- newFunctions: [],
1887
- deletedFunctions: [],
1888
- summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
1889
- };
1890
- }
1891
-
1892
2000
  // Diff paths are git-root-relative. Resolve to index.root for file lookup.
1893
2001
  // Normalize both through realpath to handle macOS /var → /private/var symlinks.
1894
2002
  let realGitRoot, realProjectRoot;
@@ -1908,6 +2016,58 @@ function diffImpact(index, options = {}) {
1908
2016
  changes.push({ ...c, gitRelativePath: c.relativePath, filePath: path.join(index.root, localRel), relativePath: localRel });
1909
2017
  }
1910
2018
 
2019
+ // fix #346: untracked files are new work too. `git diff <base>` only
2020
+ // sees tracked paths, so a session's brand-new modules were invisible to
2021
+ // the pre-commit gate until `git add -N` — a silent pass on exactly the
2022
+ // code that has never been checked. Indexed, gitignore-respecting
2023
+ // untracked source files join the working-tree diff as whole-file
2024
+ // additions (staged mode keeps its index-only meaning).
2025
+ let untrackedPaths = 0;
2026
+ if (!staged) {
2027
+ const lsArgs = ['ls-files', '--others', '--exclude-standard', '-z'];
2028
+ if (file) lsArgs.push('--', file);
2029
+ let untrackedText = '';
2030
+ try {
2031
+ untrackedText = execFileSync('git', lsArgs, {
2032
+ cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
2033
+ stdio: ['ignore', 'pipe', 'ignore'],
2034
+ });
2035
+ } catch (_) { untrackedText = ''; }
2036
+ const known = new Set(changes.map(c => c.relativePath));
2037
+ for (const localRel of untrackedText.split('\0').filter(Boolean).sort(codeUnitCompare)) {
2038
+ if (known.has(localRel)) continue;
2039
+ const filePath = path.join(index.root, localRel);
2040
+ const fileEntry = index.files.get(filePath);
2041
+ if (!fileEntry || !detectLanguage(filePath)) continue;
2042
+ let lineCount = fileEntry.lines;
2043
+ if (!Number.isFinite(lineCount)) {
2044
+ try { lineCount = fs.readFileSync(filePath, 'utf-8').split('\n').length; } catch (_) { continue; }
2045
+ }
2046
+ const addedLines = [];
2047
+ for (let i = 1; i <= lineCount; i++) addedLines.push(i);
2048
+ untrackedPaths++;
2049
+ changes.push({
2050
+ filePath, relativePath: localRel,
2051
+ gitRelativePath: projectPrefix ? `${projectPrefix}/${localRel}` : localRel,
2052
+ addedLines, deletedLines: [], untracked: true,
2053
+ });
2054
+ }
2055
+ }
2056
+
2057
+ if (changes.length === 0) {
2058
+ return {
2059
+ base: staged ? '(staged)' : base,
2060
+ changedPaths: 0,
2061
+ nonSourcePaths: 0,
2062
+ untrackedPaths: 0,
2063
+ functions: [],
2064
+ moduleLevelChanges: [],
2065
+ newFunctions: [],
2066
+ deletedFunctions: [],
2067
+ summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
2068
+ };
2069
+ }
2070
+
1911
2071
  const functions = [];
1912
2072
  const moduleLevelChanges = [];
1913
2073
  const newFunctions = [];
@@ -2111,7 +2271,10 @@ function diffImpact(index, options = {}) {
2111
2271
  const { symbol, addedLines } = data;
2112
2272
  const identityKey = `${symbol.name}\0${symbol.className || ''}`;
2113
2273
  let isNew;
2114
- if (oldSymbolIdentities !== null) {
2274
+ if (change.untracked) {
2275
+ // fix #346: nothing in an untracked file existed at the base.
2276
+ isNew = true;
2277
+ } else if (oldSymbolIdentities !== null) {
2115
2278
  isNew = !oldSymbolIdentities.has(identityKey);
2116
2279
  } else {
2117
2280
  // Fallback: 80% of body lines added and no deletions hit this symbol.
@@ -2291,6 +2454,7 @@ function diffImpact(index, options = {}) {
2291
2454
  base: staged ? '(staged)' : base,
2292
2455
  changedPaths: changes.length,
2293
2456
  nonSourcePaths,
2457
+ untrackedPaths,
2294
2458
  functions,
2295
2459
  moduleLevelChanges,
2296
2460
  newFunctions,
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
  }
@@ -16128,4 +16130,4 @@ function findCallbackUsages(index, name) {
16128
16130
  return usages;
16129
16131
  }
16130
16132
 
16131
- module.exports = { _unresolvedModuleIsGap, getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _moduleAttributeBindingReaches, _declaredFieldType, _projectTopLevelNames, _callArityCompatible, _closeCallableIdentityGroup, _overloadDiscipline, _overloadApplicable };
16133
+ module.exports = { _unresolvedModuleIsGap, _importReaches, _sameNominalPackageDir, getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _moduleAttributeBindingReaches, _declaredFieldType, _projectTopLevelNames, _callArityCompatible, _closeCallableIdentityGroup, _overloadDiscipline, _overloadApplicable };
package/core/check.js CHANGED
@@ -88,9 +88,9 @@ function check(index, options = {}) {
88
88
  const nonSourcePaths = dr?.nonSourcePaths || 0;
89
89
  let reason = 'no changes detected';
90
90
  if (changedPaths > 0 && nonSourcePaths === changedPaths) {
91
- reason = `${changedPaths} changed path(s), all outside supported source files`;
91
+ reason = `${changedPaths} changed path(s), all outside supported source files; untracked source files are included`;
92
92
  } else if (changedPaths > 0) {
93
- reason = 'no callable-symbol changes in the diff';
93
+ reason = 'no callable-symbol changes in the diff or untracked source files';
94
94
  }
95
95
  return {
96
96
  base: options.base || 'HEAD',
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,
@@ -215,6 +215,10 @@ function formatDiffImpact(result, options = {}) {
215
215
  if (result.nonSourcePaths > 0) {
216
216
  lines.push(`Note: ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
217
217
  }
218
+ // fix #346: untracked source files join the working-tree diff.
219
+ if (result.untrackedPaths > 0) {
220
+ lines.push(`Note: ${result.untrackedPaths} untracked source file(s) included as whole-file additions.`);
221
+ }
218
222
  lines.push('');
219
223
 
220
224
  // Modified functions
@@ -786,8 +786,8 @@ function formatImpact(impact, options = {}) {
786
786
  // Summary (confirmed + unverified tiers reported separately)
787
787
  const impactUnverified = impact.unverifiedSites || [];
788
788
  const unverifiedSuffix = impactUnverified.length > 0 ? ` confirmed + ${impactUnverified.length} unverified` : '';
789
- if (impact.propertyAccesses) {
790
- const pa = impact.propertyAccesses;
789
+ if (impact.propertyAccesses || impact.typeReferences) {
790
+ const pa = impact.propertyAccesses || impact.typeReferences;
791
791
  const uv = pa.unverifiedCount ? ` + ${pa.unverifiedCount} unverified` : '';
792
792
  lines.push(`DEPENDENCY SITES: ${impact.totalDependencySites} confirmed${uv}`);
793
793
  }
@@ -885,6 +885,34 @@ function formatImpact(impact, options = {}) {
885
885
  }
886
886
  }
887
887
 
888
+ // fix #345: annotation sites of a type-kind definition, tiered like the
889
+ // accessor band. The headline no longer says 0 for a type with dependents.
890
+ if (impact.typeReferences) {
891
+ const refs = impact.typeReferences;
892
+ lines.push(`${compact ? '' : '\n'}TYPE REFERENCE SITES: ${refs.confirmedCount} confirmed` +
893
+ (refs.unverifiedCount ? ` + ${refs.unverifiedCount} unverified` : '') +
894
+ (refs.excluded?.total ? ` (${refs.excluded.total} other-target)` : ''));
895
+ for (const group of refs.byFile) {
896
+ for (const site of group.sites) {
897
+ const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
898
+ lines.push(` ${group.file}:${site.line}${expr}`);
899
+ }
900
+ }
901
+ if (refs.unverifiedSites.length > 0) {
902
+ lines.push(`${compact ? '' : '\n'}UNVERIFIED TYPE REFERENCE CANDIDATES (${refs.unverifiedSites.length}) — name matches, no import link to this definition:`);
903
+ for (const site of refs.unverifiedSites.slice(0, 10)) {
904
+ const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
905
+ lines.push(` ${site.file}:${site.line}${expr} (${site.reason})`);
906
+ }
907
+ if (refs.unverifiedSites.length > 10) {
908
+ lines.push(` (+${refs.unverifiedSites.length - 10} more unverified)`);
909
+ }
910
+ }
911
+ if (refs.confirmedCount === 0 && refs.unverifiedCount === 0) {
912
+ lines.push(' (no annotation sites outside the definition)');
913
+ }
914
+ }
915
+
888
916
  // Unverified tier: visible, capped at 10 one-liners
889
917
  if (impactUnverified.length > 0) {
890
918
  lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call syntax, no binding/receiver evidence:`);
@@ -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;
@@ -84,7 +97,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
84
97
 
85
98
  if (showClient) {
86
99
  if (requests.length === 0) {
87
- if (showServer) lines.push('No client requests detected.');
100
+ if (showServer) lines.push('Client Requests: 0 — no static route literal found in any indexed file (wrapped or dynamically built request paths are invisible to this scan).');
88
101
  } else {
89
102
  if (showServer) lines.push('');
90
103
  lines.push(`Client Requests: ${requests.length}`);
@@ -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),
@@ -184,6 +184,7 @@ function impactRecords(result) {
184
184
  const summary = result.summary || {};
185
185
  notes.push(`# Diff: ${summary.modifiedFunctions || 0} modified, ${summary.newFunctions || 0} new, ${summary.deletedFunctions || 0} deleted functions; ${(result.moduleLevelChanges || []).length} file(s) with module-level changes.`);
186
186
  if (result.nonSourcePaths) notes.push(`# ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
187
+ if (result.untrackedPaths) notes.push(`# ${result.untrackedPaths} untracked source file(s) included as whole-file additions.`);
187
188
  return { records: [...new Set(out)], notes };
188
189
  }
189
190
  for (const group of result.byFile || []) {
@@ -207,6 +208,18 @@ function impactRecords(result) {
207
208
  }
208
209
  notes.push(`# PROPERTY ACCESS SITES: ${accesses.confirmedCount} confirmed, ${accesses.unverifiedCount} unverified, ${accesses.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
209
210
  }
211
+ if (result.typeReferences) {
212
+ const refs = result.typeReferences;
213
+ for (const group of refs.byFile || []) {
214
+ for (const site of group.sites || []) {
215
+ out.push(record(group.file, site.line, site.expression, 'type-reference'));
216
+ }
217
+ }
218
+ for (const site of refs.unverifiedSites || []) {
219
+ out.push(record(pathOf(site), site.line, site.expression, `unverified: ${site.reason}; type-reference`));
220
+ }
221
+ notes.push(`# TYPE REFERENCE SITES: ${refs.confirmedCount} confirmed, ${refs.unverifiedCount} unverified, ${refs.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
222
+ }
210
223
  notes.push(...accountComments(result.account));
211
224
  for (const warning of result.warnings || []) notes.push(...commentLines(warning.message));
212
225
  if (result.scopeWarning?.hint) notes.push(...commentLines(result.scopeWarning.hint));
@@ -532,6 +532,14 @@ function formatReverseTraceJson(result) {
532
532
  /**
533
533
  * Format affected-tests command output - text
534
534
  */
535
+ // fix #347: a hub symbol at depth 2 links hundreds of names per test file;
536
+ // the list is the answer only for leaves. Cap at 8 unless --all.
537
+ function linkList(names, options) {
538
+ const MAX_LINKS = options?.all ? Infinity : 8;
539
+ if (!Array.isArray(names) || names.length <= MAX_LINKS) return (names || []).join(', ');
540
+ return `${names.slice(0, MAX_LINKS).join(', ')}, +${names.length - MAX_LINKS} more`;
541
+ }
542
+
535
543
  function formatAffectedTests(result, options = {}) {
536
544
  if (!result) return 'Function not found.';
537
545
 
@@ -553,7 +561,7 @@ function formatAffectedTests(result, options = {}) {
553
561
  lines.push(`Test files to run (${summary.totalTestFiles}):`);
554
562
  lines.push('');
555
563
  for (const tf of displayFiles) {
556
- lines.push(` ${tf.file} (links: ${tf.linkedFunctions.join(', ')})`);
564
+ lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
557
565
  // Show up to 5 key matches per file
558
566
  const keyMatches = tf.matches
559
567
  .filter(m => m.matchType === 'call' || m.matchType === 'test-case')
@@ -581,7 +589,7 @@ function formatAffectedTests(result, options = {}) {
581
589
  lines.push(` Additional test files (${pat.length}):`);
582
590
  const MAX_POSSIBLE = options.all ? Infinity : 10;
583
591
  for (const tf of pat.slice(0, MAX_POSSIBLE)) {
584
- lines.push(` ${tf.file} (links: ${tf.linkedFunctions.join(', ')})`);
592
+ lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
585
593
  }
586
594
  if (pat.length > MAX_POSSIBLE) {
587
595
  lines.push(` ... ${pat.length - MAX_POSSIBLE} more (${options.allHint || 'use --all'})`);
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,
@@ -1317,34 +1317,59 @@ function pythonTargetBindsName(left, name) {
1317
1317
  return false;
1318
1318
  }
1319
1319
 
1320
- function pythonScopeBindsName(scopeNode, name) {
1321
- for (let i = 0; i < scopeNode.namedChildCount; i++) {
1322
- const child = scopeNode.namedChild(i);
1323
- if (child.type === 'function_definition' ||
1324
- child.type === 'async_function_definition' ||
1325
- child.type === 'class_definition') {
1326
- // The nested body is a separate scope, but the declaration name
1327
- // binds in this scope.
1328
- if (child.childForFieldName('name')?.text === name) return true;
1329
- continue;
1320
+ // One walk per scope body collects every name it binds at this scope level
1321
+ // (declarations, assignment targets, for/with targets; nested def/class bodies
1322
+ // are separate scopes, lambdas too). Memoized per tree by native node id: the
1323
+ // caller loop asks the same body about many names, and a per-name walk made
1324
+ // the Python build cost functions x tracked names x body size (measured 10s of
1325
+ // a 60s sequential build on a 20MB Python repo).
1326
+ const scopeBoundNamesByTree = new WeakMap();
1327
+ function pythonScopeBoundNames(scopeNode) {
1328
+ let byId = scopeBoundNamesByTree.get(scopeNode.tree);
1329
+ if (!byId) { byId = new Map(); scopeBoundNamesByTree.set(scopeNode.tree, byId); }
1330
+ const cached = byId.get(scopeNode.id);
1331
+ if (cached) return cached;
1332
+ const names = new Set();
1333
+ const addTarget = (left) => {
1334
+ if (!left) return;
1335
+ if (left.type === 'identifier') names.add(left.text);
1336
+ else if (left.type === 'pattern_list' || left.type === 'tuple_pattern') {
1337
+ for (const item of left.namedChildren) {
1338
+ if (item.type === 'identifier') names.add(item.text);
1339
+ }
1330
1340
  }
1331
- if (child.type === 'lambda') continue;
1332
- if (child.type === 'assignment' ||
1333
- child.type === 'augmented_assignment' ||
1334
- child.type === 'named_expression') {
1335
- if (pythonTargetBindsName(
1336
- child.childForFieldName('left') || child.childForFieldName('name'),
1337
- name)) return true;
1338
- } else if (child.type === 'for_statement') {
1339
- if (pythonTargetBindsName(child.childForFieldName('left'), name)) return true;
1340
- } else if (child.type === 'with_statement') {
1341
- const text = child.namedChild(0)?.text || '';
1342
- const match = text.match(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/);
1343
- if (match && match[1] === name) return true;
1344
- }
1345
- if (pythonScopeBindsName(child, name)) return true;
1346
- }
1347
- return false;
1341
+ };
1342
+ const walk = (node) => {
1343
+ for (const child of node.namedChildren) {
1344
+ if (child.type === 'function_definition' ||
1345
+ child.type === 'async_function_definition' ||
1346
+ child.type === 'class_definition') {
1347
+ const declared = child.childForFieldName('name')?.text;
1348
+ if (declared) names.add(declared);
1349
+ continue;
1350
+ }
1351
+ if (child.type === 'lambda') continue;
1352
+ if (child.type === 'assignment' ||
1353
+ child.type === 'augmented_assignment' ||
1354
+ child.type === 'named_expression') {
1355
+ addTarget(child.childForFieldName('left') || child.childForFieldName('name'));
1356
+ } else if (child.type === 'for_statement') {
1357
+ addTarget(child.childForFieldName('left'));
1358
+ } else if (child.type === 'with_statement') {
1359
+ const text = child.namedChild(0)?.text || '';
1360
+ const match = text.match(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/);
1361
+ if (match) names.add(match[1]);
1362
+ }
1363
+ walk(child);
1364
+ }
1365
+ };
1366
+ walk(scopeNode);
1367
+ byId.set(scopeNode.id, names);
1368
+ return names;
1369
+ }
1370
+
1371
+ function pythonScopeBindsName(scopeNode, name) {
1372
+ return pythonScopeBoundNames(scopeNode).has(name);
1348
1373
  }
1349
1374
 
1350
1375
  const PY_COMPREHENSIONS = new Set([
@@ -3115,6 +3140,7 @@ function findImportsInCode(code, parser) {
3115
3140
  const deferral = importDeferral(node);
3116
3141
  let modulePath = '';
3117
3142
  const names = [];
3143
+ const renames = [];
3118
3144
 
3119
3145
  for (let i = 0; i < node.namedChildCount; i++) {
3120
3146
  const child = node.namedChild(i);
@@ -3133,6 +3159,7 @@ function findImportsInCode(code, parser) {
3133
3159
  if (nameNode && aliasNode && aliasNode.text !== nameNode.text) {
3134
3160
  if (!importAliases) importAliases = [];
3135
3161
  importAliases.push({ original: nameNode.text, local: aliasNode.text });
3162
+ renames.push({ original: nameNode.text, local: aliasNode.text });
3136
3163
  }
3137
3164
  } else if (child.type === 'wildcard_import') {
3138
3165
  names.push('*');
@@ -3146,6 +3173,7 @@ function findImportsInCode(code, parser) {
3146
3173
  names,
3147
3174
  type: isRelative ? 'relative' : 'from',
3148
3175
  line,
3176
+ ...(renames.length > 0 && { renames }),
3149
3177
  ...(deferral && { deferred: true, deferredReason: deferral })
3150
3178
  });
3151
3179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.3.0",
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",