ucn 4.1.3 → 4.2.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.
@@ -99,6 +99,109 @@ function getAssignmentName(leftNode) {
99
99
  return null;
100
100
  }
101
101
 
102
+ /** True when a declaration is outside every function/class body. */
103
+ function isModuleScope(node) {
104
+ let current = node && node.parent;
105
+ while (current) {
106
+ if (current.type === 'function_declaration' || current.type === 'arrow_function' ||
107
+ current.type === 'function_expression' || current.type === 'method_definition' ||
108
+ current.type === 'generator_function_declaration' || current.type === 'generator_function' ||
109
+ current.type === 'class_body') {
110
+ return false;
111
+ }
112
+ if (current.type === 'program' || current.type === 'module') return true;
113
+ current = current.parent;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /** Unwrap common type/runtime wrappers around an object-literal registry. */
119
+ function unwrapObjectRegistry(node) {
120
+ let current = node;
121
+ while (current && (current.type === 'parenthesized_expression' ||
122
+ current.type === 'as_expression' || current.type === 'satisfies_expression' ||
123
+ current.type === 'type_assertion')) {
124
+ current = current.namedChild(0);
125
+ }
126
+ if (current && current.type === 'object') return current;
127
+ if (current && current.type === 'call_expression') {
128
+ const callee = current.childForFieldName('function');
129
+ if (callee && (callee.text === 'Object.freeze' || callee.text === 'Object.seal')) {
130
+ const args = current.childForFieldName('arguments');
131
+ const first = args && args.namedChild(0);
132
+ return unwrapObjectRegistry(first);
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function objectPropertyName(nameNode) {
139
+ if (!nameNode) return null;
140
+ if (nameNode.type === 'identifier' || nameNode.type === 'property_identifier') return nameNode.text;
141
+ if (nameNode.type === 'string') {
142
+ const raw = nameNode.text;
143
+ if (raw.length >= 2 && raw[0] === raw[raw.length - 1]) return raw.slice(1, -1);
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Index function-valued object properties. Module-scope objects are dynamic
150
+ * dispatch surfaces (`HANDLERS[command](...)`); without symbols for their
151
+ * members, calls inside them are orphaned and reachability/deadcode lie.
152
+ */
153
+ function appendObjectFunctionMembers(objectNode, functions, lines, extraFields = {}) {
154
+ if (!objectNode) return 0;
155
+ let added = 0;
156
+ for (let i = 0; i < objectNode.namedChildCount; i++) {
157
+ const prop = objectNode.namedChild(i);
158
+ let nameNode = null;
159
+ let fnNode = null;
160
+ if (prop.type === 'method_definition') {
161
+ nameNode = prop.childForFieldName('name');
162
+ fnNode = prop;
163
+ } else if (prop.type === 'pair') {
164
+ const value = prop.childForFieldName('value');
165
+ if (value && (value.type === 'function_expression' || value.type === 'arrow_function' ||
166
+ value.type === 'generator_function')) {
167
+ nameNode = prop.childForFieldName('key');
168
+ fnNode = value;
169
+ }
170
+ }
171
+ const name = objectPropertyName(nameNode);
172
+ if (!name || !fnNode) continue;
173
+
174
+ const paramsNode = fnNode.childForFieldName('parameters');
175
+ const { startLine, endLine, indent } = nodeToLocation(prop, lines);
176
+ const returnType = extractReturnType(fnNode);
177
+ const generics = extractGenerics(fnNode);
178
+ const docstring = extractJSDocstring(lines, startLine);
179
+ const paramsStructured = parseStructuredParams(paramsNode, 'javascript');
180
+ const typeAnno = buildTypeAnnotations(paramsStructured, returnType, lines, startLine, true);
181
+ const modifiers = extractModifiers(fnNode);
182
+ functions.push({
183
+ name,
184
+ params: extractParams(paramsNode),
185
+ paramsStructured,
186
+ startLine,
187
+ endLine,
188
+ indent,
189
+ isArrow: fnNode.type === 'arrow_function',
190
+ isGenerator: isGenerator(fnNode),
191
+ isAsync: modifiers.includes('async'),
192
+ modifiers,
193
+ memberAssigned: true,
194
+ registryMember: true,
195
+ ...extraFields,
196
+ ...typeAnno,
197
+ ...(generics && { generics }),
198
+ ...(docstring && { docstring }),
199
+ });
200
+ added++;
201
+ }
202
+ return added;
203
+ }
204
+
102
205
  /**
103
206
  * Extract modifiers from a declaration NODE — AST tokens, never text
104
207
  * (fix #249: the first-line regex fabricated export/async/default from
@@ -215,7 +318,7 @@ function extractDecoratorsWithArgs(node) {
215
318
  let inner = null;
216
319
  for (let i = 0; i < n.namedChildCount; i++) {
217
320
  const c = n.namedChild(i);
218
- if (c.type !== 'comment') { inner = c; break; }
321
+ if (!c.type.endsWith('comment')) { inner = c; break; }
219
322
  }
220
323
  if (!inner) return;
221
324
 
@@ -230,7 +333,7 @@ function extractDecoratorsWithArgs(node) {
230
333
  let firstStringArg = null;
231
334
  for (let j = 0; j < argsNode.namedChildCount; j++) {
232
335
  const arg = argsNode.namedChild(j);
233
- if (arg.type === 'comment') continue;
336
+ if (arg.type.endsWith('comment')) continue;
234
337
  const s = extractStringArg(arg);
235
338
  if (s && !s.interp) { firstStringArg = s.value; break; }
236
339
  if (s) { firstStringArg = s.value; break; }
@@ -463,6 +566,21 @@ function _processFunction(node, functions, processedRanges, lines) {
463
566
  }
464
567
  }
465
568
  }
569
+
570
+ // Module-scope dispatch tables (including Object.freeze /
571
+ // Object.seal wrappers). These handlers are invoked through
572
+ // computed property access, so a plain name-based call graph
573
+ // cannot discover their incoming edge. Index them explicitly
574
+ // and let reachability treat them as conservative roots.
575
+ if (!isArrow && !isFnExpr && isModuleScope(node)) {
576
+ const registryObject = unwrapObjectRegistry(valueNode);
577
+ if (registryObject) {
578
+ const added = appendObjectFunctionMembers(registryObject, functions, lines, {
579
+ registryContainer: nameNode.text,
580
+ });
581
+ if (added > 0) processedRanges.add(rangeKey);
582
+ }
583
+ }
466
584
  }
467
585
  }
468
586
  }
@@ -555,37 +673,9 @@ function _processFunction(node, functions, processedRanges, lines) {
555
673
  if (lhsText === 'module.exports' || lhsText === 'exports' ||
556
674
  lhsText.startsWith('module.exports.') || lhsText.startsWith('exports.')) {
557
675
  processedRanges.add(rangeKey);
558
- for (let pi = 0; pi < rightNode.namedChildCount; pi++) {
559
- const prop = rightNode.namedChild(pi);
560
- let nameNode = null;
561
- let fnNode = null;
562
- if (prop.type === 'method_definition') {
563
- nameNode = prop.childForFieldName('name');
564
- fnNode = prop;
565
- } else if (prop.type === 'pair') {
566
- const v = prop.childForFieldName('value');
567
- if (v && (v.type === 'function_expression' ||
568
- v.type === 'arrow_function' || v.type === 'generator_function')) {
569
- nameNode = prop.childForFieldName('key');
570
- fnNode = v;
571
- }
572
- }
573
- if (!nameNode || !fnNode) continue;
574
- const paramsNode = fnNode.childForFieldName('parameters');
575
- const { startLine, endLine, indent } = nodeToLocation(prop, lines);
576
- const paramsStructured = parseStructuredParams(paramsNode, 'javascript');
577
- functions.push({
578
- name: nameNode.text,
579
- params: extractParams(paramsNode),
580
- paramsStructured,
581
- startLine,
582
- endLine,
583
- indent,
584
- isArrow: fnNode.type === 'arrow_function',
585
- isGenerator: isGenerator(fnNode),
586
- modifiers: [],
587
- });
588
- }
676
+ appendObjectFunctionMembers(rightNode, functions, lines, {
677
+ registryContainer: lhsText,
678
+ });
589
679
  }
590
680
  }
591
681
  }
@@ -1071,6 +1161,40 @@ function extractClassMembers(classNode, codeOrLines) {
1071
1161
  ...(decorators.length > 0 && { decorators }),
1072
1162
  ...(decoratorsWithArgs.length > 0 && { decoratorsWithArgs })
1073
1163
  });
1164
+
1165
+ // TypeScript constructor parameter-properties are declared
1166
+ // fields, not ordinary parameters. Index them from the AST so
1167
+ // `constructor(private repo: Repository)` gives
1168
+ // `this.repo.save()` compiler-visible receiver evidence.
1169
+ // Accessibility and `readonly` are syntax tokens on the
1170
+ // parameter node; no text-pattern fallback is needed.
1171
+ if (name === 'constructor' && paramsNode) {
1172
+ for (let pi = 0; pi < paramsNode.namedChildCount; pi++) {
1173
+ const param = paramsNode.namedChild(pi);
1174
+ if (!['required_parameter', 'optional_parameter'].includes(param.type)) continue;
1175
+ const access = Array.from({ length: param.namedChildCount }, (_, ci) => param.namedChild(ci))
1176
+ .find(n => n.type === 'accessibility_modifier');
1177
+ const readonly = Array.from({ length: param.childCount }, (_, ci) => param.child(ci))
1178
+ .some(n => n.type === 'readonly');
1179
+ if (!access && !readonly) continue;
1180
+ const pattern = param.childForFieldName('pattern');
1181
+ if (!pattern || pattern.type !== 'identifier') continue;
1182
+ const typeNode = param.childForFieldName('type');
1183
+ const fieldType = typeNode
1184
+ ? typeNode.text.replace(/^:\s*/, '').trim() : undefined;
1185
+ const loc = nodeToLocation(param, code);
1186
+ members.push({
1187
+ name: pattern.text,
1188
+ startLine: loc.startLine,
1189
+ endLine: loc.endLine,
1190
+ memberType: 'field',
1191
+ ...(fieldType && { fieldType }),
1192
+ ...(access && ['private', 'protected'].includes(access.text) && {
1193
+ modifiers: [access.text],
1194
+ }),
1195
+ });
1196
+ }
1197
+ }
1074
1198
  }
1075
1199
  }
1076
1200
 
@@ -1274,6 +1398,7 @@ function parse(code, parser) {
1274
1398
  functions,
1275
1399
  classes,
1276
1400
  stateObjects,
1401
+ ...(tree.rootNode.hasError && { parseRecovery: true }),
1277
1402
  imports: [], // Handled by core/imports.js
1278
1403
  exports: [] // Handled by core/imports.js
1279
1404
  };
@@ -1398,7 +1523,11 @@ function findCallsInCode(code, parser) {
1398
1523
  const tree = parseTree(parser, code);
1399
1524
  const calls = [];
1400
1525
  const functionStack = []; // Stack of { name, startLine, endLine }
1401
- const aliases = new Map(); // Track local aliases: aliasName -> originalName (string or string[])
1526
+ // Local aliases with lexical ownership. A flat aliasName→target map leaks
1527
+ // block locals into the rest of a module (`let effect = batchedEffect`
1528
+ // inside a loop rewrote a later module-level `effect()` call), producing
1529
+ // false external edges and caller/callee disagreement.
1530
+ const aliases = new Map(); // aliasName -> [{ target, declarationIndex, scopeStart, scopeEnd }]
1402
1531
  const nonCallableNames = new Set(); // Track names assigned non-callable values
1403
1532
  const localVarTypes = new Map(); // Track local variable types: varName -> typeName (for receiverType inference)
1404
1533
  // Names whose type came from a DECLARED annotation (TS `x: Foo` / typed
@@ -1418,7 +1547,7 @@ function findCallsInCode(code, parser) {
1418
1547
  if (!argsNode) return null;
1419
1548
  for (let i = 0; i < argsNode.namedChildCount; i++) {
1420
1549
  const arg = argsNode.namedChild(i);
1421
- if (arg.type === 'comment') continue;
1550
+ if (arg.type.endsWith('comment')) continue;
1422
1551
  return _extractStringArg(arg);
1423
1552
  }
1424
1553
  return null;
@@ -1434,7 +1563,7 @@ function findCallsInCode(code, parser) {
1434
1563
  let count = 0;
1435
1564
  for (let i = 0; i < argsNode.namedChildCount; i++) {
1436
1565
  const arg = argsNode.namedChild(i);
1437
- if (arg.type === 'comment') continue;
1566
+ if (arg.type.endsWith('comment')) continue;
1438
1567
  count++;
1439
1568
  }
1440
1569
  return count;
@@ -1452,7 +1581,7 @@ function findCallsInCode(code, parser) {
1452
1581
  let target = null;
1453
1582
  for (let i = 0; i < argsNode.namedChildCount; i++) {
1454
1583
  const arg = argsNode.namedChild(i);
1455
- if (arg.type === 'comment') continue;
1584
+ if (arg.type.endsWith('comment')) continue;
1456
1585
  if (idx === argIdx) { target = arg; break; }
1457
1586
  idx++;
1458
1587
  }
@@ -1589,6 +1718,38 @@ function findCallsInCode(code, parser) {
1589
1718
  : null;
1590
1719
  };
1591
1720
 
1721
+ const aliasScope = (declarator) => {
1722
+ const declaration = declarator.parent;
1723
+ const lexical = declaration?.type === 'lexical_declaration';
1724
+ for (let p = declaration?.parent; p; p = p.parent) {
1725
+ if (lexical && (p.type === 'statement_block' || p.type === 'switch_body' ||
1726
+ p.type === 'for_statement' || p.type === 'for_in_statement')) return p;
1727
+ if (isFunctionNode(p) || p.type === 'program' || p.type === 'module') return p;
1728
+ }
1729
+ return tree.rootNode;
1730
+ };
1731
+ const recordAlias = (name, target, declarator) => {
1732
+ const scope = aliasScope(declarator);
1733
+ if (!aliases.has(name)) aliases.set(name, []);
1734
+ aliases.get(name).push({
1735
+ target,
1736
+ declarationIndex: declarator.startIndex,
1737
+ scopeStart: scope.startIndex,
1738
+ scopeEnd: scope.endIndex,
1739
+ });
1740
+ };
1741
+ const resolveAlias = (name, callNode) => {
1742
+ const records = aliases.get(name);
1743
+ if (!records) return undefined;
1744
+ let best;
1745
+ for (const record of records) {
1746
+ if (record.declarationIndex > callNode.startIndex ||
1747
+ callNode.startIndex < record.scopeStart || callNode.endIndex > record.scopeEnd) continue;
1748
+ if (!best || record.declarationIndex > best.declarationIndex) best = record;
1749
+ }
1750
+ return best && best.target;
1751
+ };
1752
+
1592
1753
  // fix #203: does a declaration node declare `name` (incl. shallow destructuring)?
1593
1754
  const _declaresName = (declNode, name) => {
1594
1755
  for (let i = 0; i < declNode.namedChildCount; i++) {
@@ -1698,7 +1859,7 @@ function findCallsInCode(code, parser) {
1698
1859
  }
1699
1860
  if (nameNode?.type === 'identifier' && initNode?.type === 'identifier') {
1700
1861
  // Simple alias: const p = parse
1701
- aliases.set(nameNode.text, initNode.text);
1862
+ recordAlias(nameNode.text, initNode.text, node);
1702
1863
  }
1703
1864
  // Ternary alias: const fn = cond ? parseCSV : parseJSON → both targets
1704
1865
  if (nameNode?.type === 'identifier' && initNode?.type === 'ternary_expression') {
@@ -1707,7 +1868,7 @@ function findCallsInCode(code, parser) {
1707
1868
  const targets = [];
1708
1869
  if (consequence?.type === 'identifier') targets.push(consequence.text);
1709
1870
  if (alternative?.type === 'identifier') targets.push(alternative.text);
1710
- if (targets.length > 0) aliases.set(nameNode.text, targets);
1871
+ if (targets.length > 0) recordAlias(nameNode.text, targets, node);
1711
1872
  }
1712
1873
  // Destructured rename: const { parse: csvParse } = require(...)
1713
1874
  if (nameNode?.type === 'object_pattern') {
@@ -1718,7 +1879,7 @@ function findCallsInCode(code, parser) {
1718
1879
  const value = prop.childForFieldName('value');
1719
1880
  if ((key?.type === 'identifier' || key?.type === 'property_identifier') &&
1720
1881
  value?.type === 'identifier') {
1721
- aliases.set(value.text, key.text);
1882
+ recordAlias(value.text, key.text, node);
1722
1883
  }
1723
1884
  }
1724
1885
  }
@@ -1819,6 +1980,36 @@ function findCallsInCode(code, parser) {
1819
1980
  }
1820
1981
  }
1821
1982
 
1983
+ // Tree-sitter recovery can flatten a valid constructor into an ERROR
1984
+ // node when an earlier unsupported TypeScript construct destabilizes
1985
+ // the surrounding declaration. Keep this AST-first: inspect recovery
1986
+ // tokens/named children only—never source regex. Example (Hono's
1987
+ // overload-heavy factory file): ERROR children `app`, `=`, `new`,
1988
+ // `Hono` for `const app = new Hono<E>(...)`. Without this, the class
1989
+ // had a false zero-caller answer even though the identifier and `new`
1990
+ // token survived in the syntax tree.
1991
+ if (node.type === 'ERROR') {
1992
+ for (let i = 0; i < node.childCount - 1; i++) {
1993
+ const token = node.child(i);
1994
+ if (token.type !== 'new' || token.isNamed) continue;
1995
+ let ctorNode = null;
1996
+ for (let j = i + 1; j < node.childCount; j++) {
1997
+ const candidate = node.child(j);
1998
+ if (candidate.isNamed) { ctorNode = candidate; break; }
1999
+ }
2000
+ const ctorName = jsConstructorTypeName(ctorNode);
2001
+ if (!ctorName) continue;
2002
+ calls.push({
2003
+ name: ctorName,
2004
+ line: ctorNode.startPosition.row + 1,
2005
+ isMethod: ctorNode.type === 'member_expression',
2006
+ isConstructor: true,
2007
+ parseRecovery: true,
2008
+ enclosingFunction: getCurrentEnclosingFunction(),
2009
+ });
2010
+ }
2011
+ }
2012
+
1822
2013
  // Handle regular function calls: foo(), obj.foo(), foo.call()
1823
2014
  if (node.type === 'call_expression') {
1824
2015
  const funcNode = node.childForFieldName('function');
@@ -1833,7 +2024,7 @@ function findCallsInCode(code, parser) {
1833
2024
 
1834
2025
  if (funcNode.type === 'identifier') {
1835
2026
  // Direct call: foo()
1836
- const alias = aliases.get(funcNode.text);
2027
+ const alias = resolveAlias(funcNode.text, node);
1837
2028
  const resolvedName = typeof alias === 'string' ? alias : undefined;
1838
2029
  const resolvedNames = Array.isArray(alias) ? alias : undefined;
1839
2030
  const firstArg = getFirstStringArg(node);
@@ -2031,7 +2222,7 @@ function findCallsInCode(code, parser) {
2031
2222
  for (let i = 0; i < argsNode.namedChildCount; i++) {
2032
2223
  const arg = argsNode.namedChild(i);
2033
2224
  // Skip non-argument nodes (e.g. commas)
2034
- if (arg.type === 'comment') continue;
2225
+ if (arg.type.endsWith('comment')) continue;
2035
2226
  // Only check args at callback positions (null = all positions)
2036
2227
  const isCallbackPos = callbackIndices === null || callbackIndices === undefined || callbackIndices.has(argIdx);
2037
2228
  if (isCallbackPos) {
@@ -322,8 +322,8 @@ function extractDecorators(node) {
322
322
  * `X: TypeAlias = ...` assignments become 'type' symbols with aliasOf.
323
323
  */
324
324
  function _processTypeAlias(node, classes, processedRanges, lines) {
325
- let name = null;
326
- let valueText = null;
325
+ let name;
326
+ let valueText;
327
327
  if (node.type === 'type_alias_statement') {
328
328
  // grammar shape: type <left> = <right>
329
329
  const left = node.namedChild(0);
@@ -543,6 +543,7 @@ function parse(code, parser) {
543
543
  functions,
544
544
  classes,
545
545
  stateObjects,
546
+ ...(tree.rootNode.hasError && { parseRecovery: true }),
546
547
  ...(moduleAssigned.size > 0 && { moduleAssignedNames: [...moduleAssigned].sort() }),
547
548
  imports: [],
548
549
  exports: []
@@ -654,17 +655,24 @@ function assignmentTargetOf(callNode) {
654
655
  }
655
656
 
656
657
  /**
657
- * Type name from a constructor-call callee: ClassName(...) or pkg.ClassName(...).
658
- * Uppercase-first heuristic (Python class naming convention).
658
+ * Type identity hint from a constructor-call callee: ClassName(...) or
659
+ * pkg.ClassName(...). Preserve the qualifier: dropping `threading` from
660
+ * `threading.Thread()` turns an external class into an unqualified project
661
+ * type name and can falsely confirm `thread.join()` against Project.join.
662
+ * Uppercase-first remains a Python class-naming heuristic, so callers use the
663
+ * qualifier as routing/provenance evidence rather than a hidden hard claim.
659
664
  */
660
- function constructorTypeName(funcNode) {
665
+ function constructorTypeInfo(funcNode) {
661
666
  if (!funcNode) return undefined;
662
667
  if (funcNode.type === 'identifier') {
663
- return /^[A-Z]/.test(funcNode.text) ? funcNode.text : undefined;
668
+ return /^[A-Z]/.test(funcNode.text) ? { type: funcNode.text } : undefined;
664
669
  }
665
670
  if (funcNode.type === 'attribute') {
666
671
  const attr = funcNode.childForFieldName('attribute');
667
- return attr && /^[A-Z]/.test(attr.text) ? attr.text : undefined;
672
+ const object = funcNode.childForFieldName('object');
673
+ return attr && /^[A-Z]/.test(attr.text)
674
+ ? { type: attr.text, qualifier: object?.type === 'identifier' ? object.text : undefined }
675
+ : undefined;
668
676
  }
669
677
  return undefined;
670
678
  }
@@ -676,6 +684,7 @@ function findCallsInCode(code, parser) {
676
684
  const aliases = new Map(); // Track local aliases: aliasName -> originalName
677
685
  const nonCallableNames = new Set(); // Track names assigned non-callable values
678
686
  const localVarTypes = new Map(); // Track local variable types: varName -> typeName (for receiverType inference)
687
+ const localVarTypeQualifiers = new Map(); // varName -> imported module alias that owns the inferred type
679
688
  // Member-access aliases (fix #218): `append = output.append` makes a later
680
689
  // bare `append(part)` a METHOD call on `output` — it must carry the
681
690
  // receiver's evidence, never bind by bare name to a same-file def
@@ -687,6 +696,7 @@ function findCallsInCode(code, parser) {
687
696
  const memberAliasesStack = []; // function-scoped save/restore, like localVarTypes
688
697
  const moduleAliases = new Set(); // Names bound to MODULES (import httpx / import numpy as np)
689
698
  const localVarTypesStack = []; // Stack for function-scoped save/restore of localVarTypes
699
+ const localVarTypeQualifiersStack = [];
690
700
 
691
701
  // Helper: extract first string-arg literal from a call node.
692
702
  // Used by route extraction to capture path arg of requests.get('/users'), httpx.get('/users') etc.
@@ -697,7 +707,7 @@ function findCallsInCode(code, parser) {
697
707
  if (!argsNode) return null;
698
708
  for (let i = 0; i < argsNode.namedChildCount; i++) {
699
709
  const arg = argsNode.namedChild(i);
700
- if (arg.type === 'comment') continue;
710
+ if (arg.type.endsWith('comment')) continue;
701
711
  // Handle f-string explicitly
702
712
  if (arg.type === 'string') {
703
713
  // f-string detection: tree-sitter-python wraps interpolations as 'interpolation' children.
@@ -877,6 +887,7 @@ function findCallsInCode(code, parser) {
877
887
  });
878
888
  // Save localVarTypes so inner declarations don't leak to sibling functions
879
889
  localVarTypesStack.push(new Map(localVarTypes));
890
+ localVarTypeQualifiersStack.push(new Map(localVarTypeQualifiers));
880
891
  memberAliasesStack.push(new Map(memberAliases));
881
892
  }
882
893
 
@@ -902,8 +913,15 @@ function findCallsInCode(code, parser) {
902
913
  const target = value.namedChildCount > 1 ? value.namedChild(value.namedChildCount - 1) : null;
903
914
  const targetId = target?.type === 'as_pattern_target' ? target.namedChild(0) : null;
904
915
  if (ctx?.type === 'call' && targetId?.type === 'identifier') {
905
- const ctorName = constructorTypeName(ctx.childForFieldName('function'));
906
- if (ctorName) localVarTypes.set(targetId.text, ctorName);
916
+ const ctor = constructorTypeInfo(ctx.childForFieldName('function'));
917
+ if (ctor) {
918
+ localVarTypes.set(targetId.text, ctor.type);
919
+ if (ctor.qualifier && moduleAliases.has(ctor.qualifier)) {
920
+ localVarTypeQualifiers.set(targetId.text, ctor.qualifier);
921
+ } else {
922
+ localVarTypeQualifiers.delete(targetId.text);
923
+ }
924
+ }
907
925
  }
908
926
  }
909
927
  }
@@ -926,7 +944,14 @@ function findCallsInCode(code, parser) {
926
944
  // type stale — nearest-preceding-assignment semantics (#199's
927
945
  // documented rule). Without this, `x = ""; x = render(); x.m()`
928
946
  // would carry str and falsely exclude project methods.
929
- if (!typeNode) localVarTypes.delete(left.text);
947
+ if (!typeNode) {
948
+ localVarTypes.delete(left.text);
949
+ localVarTypeQualifiers.delete(left.text);
950
+ } else {
951
+ // An annotation is the authoritative type source; a
952
+ // previous constructor qualifier must not survive it.
953
+ localVarTypeQualifiers.delete(left.text);
954
+ }
930
955
  // Literal assignment types the variable (fix #218):
931
956
  // ansi_bytes = b"…" → bytes; out = [] → list. Compiler-true,
932
957
  // same trust grade as literal receivers ({}.get() → dict).
@@ -989,9 +1014,14 @@ function findCallsInCode(code, parser) {
989
1014
  nonCallableNames.add(left.text);
990
1015
  // Infer type from constructor call: x = ClassName(...) or
991
1016
  // x = pkg.ClassName(...). Python convention: classes start uppercase
992
- const ctorName = constructorTypeName(right.childForFieldName('function'));
993
- if (ctorName) {
994
- localVarTypes.set(left.text, ctorName);
1017
+ const ctor = constructorTypeInfo(right.childForFieldName('function'));
1018
+ if (ctor) {
1019
+ localVarTypes.set(left.text, ctor.type);
1020
+ if (ctor.qualifier && moduleAliases.has(ctor.qualifier)) {
1021
+ localVarTypeQualifiers.set(left.text, ctor.qualifier);
1022
+ } else {
1023
+ localVarTypeQualifiers.delete(left.text);
1024
+ }
995
1025
  }
996
1026
  }
997
1027
  // Third: subscript/attribute access results are non-callable data
@@ -1023,7 +1053,7 @@ function findCallsInCode(code, parser) {
1023
1053
  if (callArgsNode) {
1024
1054
  for (let i = 0; i < callArgsNode.namedChildCount; i++) {
1025
1055
  const arg = callArgsNode.namedChild(i);
1026
- if (arg.type === 'comment') continue;
1056
+ if (arg.type.endsWith('comment')) continue;
1027
1057
  if (arg.type === 'list_splat' || arg.type === 'dictionary_splat') argSpread = true;
1028
1058
  argCount++;
1029
1059
  }
@@ -1142,6 +1172,9 @@ function findCallsInCode(code, parser) {
1142
1172
  const receiverType = receiver
1143
1173
  ? localVarTypes.get(receiver)
1144
1174
  : (objNode ? PY_LITERAL_RECEIVER_TYPES[objNode.type] : undefined);
1175
+ const receiverTypeQualifier = receiver
1176
+ ? localVarTypeQualifiers.get(receiver)
1177
+ : undefined;
1145
1178
  // Module receiver (httpx.get()) — unless locally shadowed
1146
1179
  // by a typed instance binding
1147
1180
  const receiverIsModule = !!receiver && moduleAliases.has(receiver) &&
@@ -1156,6 +1189,7 @@ function findCallsInCode(code, parser) {
1156
1189
  isMethod: true,
1157
1190
  receiver,
1158
1191
  ...(receiverType && { receiverType }),
1192
+ ...(receiverTypeQualifier && { receiverTypeQualifier }),
1159
1193
  ...(receiverIsModule && { receiverIsModule: true }),
1160
1194
  ...(selfAttribute && { selfAttribute }),
1161
1195
  ...(receiverCall && { receiverCall }),
@@ -1234,6 +1268,11 @@ function findCallsInCode(code, parser) {
1234
1268
  localVarTypes.clear();
1235
1269
  for (const [k, v] of saved) localVarTypes.set(k, v);
1236
1270
  }
1271
+ const savedQualifiers = localVarTypeQualifiersStack.pop();
1272
+ if (savedQualifiers) {
1273
+ localVarTypeQualifiers.clear();
1274
+ for (const [k, v] of savedQualifiers) localVarTypeQualifiers.set(k, v);
1275
+ }
1237
1276
  const savedAliases = memberAliasesStack.pop();
1238
1277
  if (savedAliases) {
1239
1278
  memberAliases.clear();