ucn 5.3.0 → 5.3.1

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
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/callers.js CHANGED
@@ -16128,4 +16128,4 @@ function findCallbackUsages(index, name) {
16128
16128
  return usages;
16129
16129
  }
16130
16130
 
16131
- module.exports = { _unresolvedModuleIsGap, getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _moduleAttributeBindingReaches, _declaredFieldType, _projectTopLevelNames, _callArityCompatible, _closeCallableIdentityGroup, _overloadDiscipline, _overloadApplicable };
16131
+ 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',
@@ -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:`);
@@ -84,7 +84,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
84
84
 
85
85
  if (showClient) {
86
86
  if (requests.length === 0) {
87
- if (showServer) lines.push('No client requests detected.');
87
+ 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
88
  } else {
89
89
  if (showServer) lines.push('');
90
90
  lines.push(`Client Requests: ${requests.length}`);
@@ -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'})`);
@@ -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([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.3.0",
3
+ "version": "5.3.1",
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",