ucn 5.2.1 → 5.3.0

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.
@@ -408,6 +408,24 @@ function pythonAliasBase(node) {
408
408
  return base ? (PY_ALIAS_RUNTIME_TYPES.get(base) || base) : null;
409
409
  }
410
410
 
411
+ function pythonAliasMembers(node) {
412
+ const current = unwrapTypeNode(node);
413
+ if (!current) return [];
414
+ if (current.type === 'binary_operator' && current.text.includes('|')) {
415
+ return [...new Set([
416
+ ...pythonAliasMembers(current.namedChild(0)),
417
+ ...pythonAliasMembers(current.namedChild(1)),
418
+ ])];
419
+ }
420
+ if (current.type === 'subscript' || current.type === 'generic_type') {
421
+ const parts = genericTypeParts(current);
422
+ if (!parts || !['Union', 'Optional'].includes(parts.base)) return [];
423
+ return [...new Set(parts.args.flatMap(pythonAliasMembers))];
424
+ }
425
+ const name = typeNameFromExpr(current);
426
+ return name && name !== 'None' ? [name] : [];
427
+ }
428
+
411
429
  /**
412
430
  * Python type aliases: PEP 695 `type X = int`, annotated
413
431
  * `X: TypeAlias = ...`, and module-scope static aliases such as
@@ -448,6 +466,7 @@ function _processTypeAlias(node, classes, processedRanges, lines) {
448
466
  processedRanges.add(rangeKey);
449
467
  const { startLine, endLine } = nodeToLocation(node, lines);
450
468
  const aliasOf = pythonAliasBase(valueNode);
469
+ const aliasMembers = pythonAliasMembers(valueNode);
451
470
  classes.push({
452
471
  name,
453
472
  type: 'type',
@@ -456,6 +475,7 @@ function _processTypeAlias(node, classes, processedRanges, lines) {
456
475
  methods: [],
457
476
  members: [],
458
477
  ...(aliasOf && { aliasOf }),
478
+ ...(aliasMembers.length > 1 && { aliasMembers }),
459
479
  });
460
480
  return true;
461
481
  }
@@ -969,8 +989,30 @@ function isinstanceTypes(condition, receiverName) {
969
989
  function narrowedReceiverType(refNode, receiverName, declaredUnion) {
970
990
  for (let current = refNode; current?.parent; current = current.parent) {
971
991
  const parent = current.parent;
972
- if (parent.type === 'if_statement') {
992
+ if (parent.type === 'if_statement' || parent.type === 'elif_clause') {
973
993
  const condition = parent.childForFieldName('condition');
994
+ const noneIdentity = condition?.type === 'comparison_operator' &&
995
+ condition.namedChildCount === 2 &&
996
+ condition.children.find(child =>
997
+ child.type === 'is' || child.type === 'is not');
998
+ const noneIndex = noneIdentity
999
+ ? [condition.namedChild(0), condition.namedChild(1)]
1000
+ .findIndex(child => child?.type === 'none')
1001
+ : -1;
1002
+ const compared = noneIndex >= 0
1003
+ ? condition.namedChild(1 - noneIndex) : null;
1004
+ if (compared?.type === 'identifier' &&
1005
+ compared.text === receiverName && declaredUnion?.length) {
1006
+ const remaining = declaredUnion.filter(type => type !== 'None');
1007
+ const consequence = parent.childForFieldName('consequence');
1008
+ const alternative = parent.childForFieldName('alternative');
1009
+ const inNonNullBranch = noneIdentity.type === 'is not'
1010
+ ? nodeContains(consequence, refNode)
1011
+ : nodeContains(alternative, refNode);
1012
+ if (inNonNullBranch && remaining.length === 1) {
1013
+ return remaining[0];
1014
+ }
1015
+ }
974
1016
  const positive = isinstanceTypes(condition, receiverName);
975
1017
  if (positive.length === 0) continue;
976
1018
  const consequence = parent.childForFieldName('consequence');
@@ -1122,18 +1164,21 @@ function contextTargetOf(callNode) {
1122
1164
  * pkg.ClassName(...). Preserve the qualifier: dropping `threading` from
1123
1165
  * `threading.Thread()` turns an external class into an unqualified project
1124
1166
  * type name and can falsely confirm `thread.join()` against Project.join.
1125
- * Uppercase-first remains a Python class-naming heuristic, so callers use the
1126
- * qualifier as routing/provenance evidence rather than a hidden hard claim.
1167
+ * PascalCase after optional privacy underscores remains a Python class-naming
1168
+ * heuristic, so callers use the qualifier as routing/provenance evidence
1169
+ * rather than a hidden hard claim. Private classes such as `_ClassBuilder`
1170
+ * are ordinary constructor values and must retain their type identity.
1127
1171
  */
1128
1172
  function constructorTypeInfo(funcNode) {
1129
1173
  if (!funcNode) return undefined;
1130
1174
  if (funcNode.type === 'identifier') {
1131
- return /^[A-Z]/.test(funcNode.text) ? { type: funcNode.text } : undefined;
1175
+ return /^_*[A-Z]/.test(funcNode.text)
1176
+ ? { type: funcNode.text } : undefined;
1132
1177
  }
1133
1178
  if (funcNode.type === 'attribute') {
1134
1179
  const attr = funcNode.childForFieldName('attribute');
1135
1180
  const object = funcNode.childForFieldName('object');
1136
- return attr && /^[A-Z]/.test(attr.text)
1181
+ return attr && /^_*[A-Z]/.test(attr.text)
1137
1182
  ? { type: attr.text, qualifier: object?.text || undefined }
1138
1183
  : undefined;
1139
1184
  }
@@ -1307,11 +1352,73 @@ const PY_COMPREHENSIONS = new Set([
1307
1352
  'dictionary_comprehension',
1308
1353
  ]);
1309
1354
 
1355
+ // Whether this reference is evaluated with `name` bound by an enclosing
1356
+ // for/comprehension target. Keep the check lexical: a nested function has its
1357
+ // own scope, and the iterable expression of `for x in source(x)` sees the
1358
+ // outer x rather than the new loop binding.
1359
+ function isPythonIterationBindingAt(refNode, name) {
1360
+ for (let parent = refNode?.parent; parent; parent = parent.parent) {
1361
+ if (parent.type === 'for_statement' &&
1362
+ pythonTargetBindsName(parent.childForFieldName('left'), name)) {
1363
+ const body = parent.childForFieldName('body');
1364
+ if (nodeContains(body, refNode)) return true;
1365
+ }
1366
+ if (PY_COMPREHENSIONS.has(parent.type)) {
1367
+ for (let i = 0; i < parent.namedChildCount; i++) {
1368
+ const clause = parent.namedChild(i);
1369
+ if (clause.type !== 'for_in_clause' ||
1370
+ !pythonTargetBindsName(
1371
+ clause.childForFieldName('left'), name)) continue;
1372
+ const iterable = clause.childForFieldName('right');
1373
+ if (!nodeContains(iterable, refNode)) return true;
1374
+ }
1375
+ }
1376
+ if (parent.type === 'function_definition' ||
1377
+ parent.type === 'async_function_definition' ||
1378
+ parent.type === 'lambda') break;
1379
+ }
1380
+ return false;
1381
+ }
1382
+
1383
+ // Stronger subset used for dispatch tiering: an element drawn from an
1384
+ // identifier/attribute iterable has unknown provenance. Direct call
1385
+ // producers are handled separately by assignedIter flow: external calls
1386
+ // demote there, while a project-internal producer intentionally keeps the
1387
+ // measured single-owner rule (#294's counter-probe).
1388
+ function isPythonUnprovenIterationBindingAt(refNode, name) {
1389
+ for (let parent = refNode?.parent; parent; parent = parent.parent) {
1390
+ if (parent.type === 'for_statement' &&
1391
+ pythonTargetBindsName(parent.childForFieldName('left'), name)) {
1392
+ const body = parent.childForFieldName('body');
1393
+ if (nodeContains(body, refNode)) {
1394
+ return unwrapTypeNode(parent.childForFieldName('right'))?.type !== 'call';
1395
+ }
1396
+ }
1397
+ if (PY_COMPREHENSIONS.has(parent.type)) {
1398
+ for (let i = 0; i < parent.namedChildCount; i++) {
1399
+ const clause = parent.namedChild(i);
1400
+ if (clause.type !== 'for_in_clause' ||
1401
+ !pythonTargetBindsName(
1402
+ clause.childForFieldName('left'), name)) continue;
1403
+ const iterable = clause.childForFieldName('right');
1404
+ if (!nodeContains(iterable, refNode)) {
1405
+ return unwrapTypeNode(iterable)?.type !== 'call';
1406
+ }
1407
+ }
1408
+ }
1409
+ if (parent.type === 'function_definition' ||
1410
+ parent.type === 'async_function_definition' ||
1411
+ parent.type === 'lambda') break;
1412
+ }
1413
+ return false;
1414
+ }
1415
+
1310
1416
  // Python locals are function-scoped: an assignment anywhere in the function
1311
1417
  // shadows an imported module for every reference in that function. Keep this
1312
1418
  // shared between call records and reference records so plan cannot promote a
1313
1419
  // receiver that callers would reject as locally rebound.
1314
1420
  function isPythonNameShadowedAt(refNode, name) {
1421
+ if (isPythonIterationBindingAt(refNode, name)) return true;
1315
1422
  for (let parent = refNode.parent; parent; parent = parent.parent) {
1316
1423
  if (PY_COMPREHENSIONS.has(parent.type)) {
1317
1424
  for (let i = 0; i < parent.namedChildCount; i++) {
@@ -1526,14 +1633,20 @@ function findCallsInCode(code, parser) {
1526
1633
  });
1527
1634
  const functionStack = []; // Stack of { name, startLine, endLine }
1528
1635
  const aliases = new Map(); // Track local aliases: aliasName -> originalName
1636
+ const aliasesStack = [];
1529
1637
  const nonCallableNames = new Set(); // Track names assigned non-callable values
1638
+ const nonCallableNamesStack = [];
1530
1639
  const localVarTypes = new Map(); // Track local variable types: varName -> typeName (for receiverType inference)
1531
1640
  const declaredVarTypes = new Map(); // Compiler-checked annotations survive later assignments
1532
1641
  const localVarTypeQualifiers = new Map(); // varName -> imported module alias that owns the inferred type
1533
- const localVarUnionTypes = new Map(); // varName -> concrete PEP 604 alternatives
1642
+ // varName -> concrete union alternatives. `None` is retained only for a
1643
+ // complete constructor/nullable branch join so a later identity guard can
1644
+ // narrow it without typing unguarded calls.
1645
+ const localVarUnionTypes = new Map();
1534
1646
  const localIterableTypes = new Map(); // iterable binding -> loop-variable types
1535
1647
  const localIterationSources = new Map(); // loop variable -> declared iterable path + tuple index
1536
1648
  const localDictValueTypes = new Map(); // local dict -> exact string-key value types
1649
+ const localSubscriptSources = new Map(); // local value -> exact typed container selected by []
1537
1650
  const localVarStdlibContracts = new Map(); // variable -> stdlib module proving its type flow
1538
1651
  const assignmentRhsReceiverTypes = new Map(); // call-node id -> pre-assignment receiver type
1539
1652
  const constructedReceiverVars = new Set(); // exact constructor-result bindings
@@ -1547,7 +1660,14 @@ function findCallsInCode(code, parser) {
1547
1660
  // rewritten call is then receiver-blind and routes through dispatch tiering.
1548
1661
  const memberAliases = new Map();
1549
1662
  const memberAliasesStack = []; // function-scoped save/restore, like localVarTypes
1663
+ // Exact class-value aliases: `_Segment = Segment` makes later
1664
+ // `_Segment.line()` dispatch through the imported/local class object.
1665
+ // Keep this separate from callable aliases: receiver evidence must be
1666
+ // scope-local, position-aware, and invalidated by every later write.
1667
+ const classValueAliases = new Map();
1668
+ const classValueAliasesStack = [];
1550
1669
  const moduleAliases = new Set(); // Names bound to MODULES (import httpx / import numpy as np)
1670
+ const moduleImportSpecifiers = new Set(); // Exact dotted imports: import rich.repr
1551
1671
  const localVarTypesStack = []; // Stack for function-scoped save/restore of localVarTypes
1552
1672
  const declaredVarTypesStack = [];
1553
1673
  const localVarTypeQualifiersStack = [];
@@ -1555,6 +1675,7 @@ function findCallsInCode(code, parser) {
1555
1675
  const localIterableTypesStack = [];
1556
1676
  const localIterationSourcesStack = [];
1557
1677
  const localDictValueTypesStack = [];
1678
+ const localSubscriptSourcesStack = [];
1558
1679
  const localVarStdlibContractsStack = [];
1559
1680
  const constructedReceiverVarsStack = [];
1560
1681
  const withBindingVarsStack = [];
@@ -1644,6 +1765,33 @@ function findCallsInCode(code, parser) {
1644
1765
  };
1645
1766
 
1646
1767
  const isShadowedByLocal = isPythonNameShadowedAt;
1768
+ const isDirectScopeAssignment = assignment => {
1769
+ const statement = assignment?.parent?.type === 'expression_statement'
1770
+ ? assignment.parent : assignment;
1771
+ const parent = statement?.parent;
1772
+ if (parent?.type === 'module') return true;
1773
+ if (parent?.type !== 'block') return false;
1774
+ return ['function_definition', 'async_function_definition']
1775
+ .includes(parent.parent?.type);
1776
+ };
1777
+ const functionParameterBindsName = (functionNode, name) => {
1778
+ const params = functionNode?.childForFieldName('parameters');
1779
+ if (!params) return false;
1780
+ for (let i = 0; i < params.namedChildCount; i++) {
1781
+ const param = params.namedChild(i);
1782
+ let paramName = param.type === 'identifier'
1783
+ ? param
1784
+ : (param.childForFieldName('name') || param.namedChild(0));
1785
+ if (paramName && ['dictionary_splat_pattern', 'list_splat_pattern']
1786
+ .includes(paramName.type)) {
1787
+ paramName = paramName.namedChild(0);
1788
+ }
1789
+ if (paramName?.type === 'identifier' && paramName.text === name) {
1790
+ return true;
1791
+ }
1792
+ }
1793
+ return false;
1794
+ };
1647
1795
  const exactConstructorInfo = funcNode => {
1648
1796
  const ctor = constructorTypeInfo(funcNode);
1649
1797
  if (!ctor) return undefined;
@@ -1660,6 +1808,194 @@ function findCallsInCode(code, parser) {
1660
1808
  return moduleAliases.has(root) && !isShadowedByLocal(funcNode, root)
1661
1809
  ? ctor : undefined;
1662
1810
  };
1811
+ // Compiler-stable two-branch join (#330):
1812
+ // if value is not None and not isinstance(value, T): out = T(value)
1813
+ // else: out = value
1814
+ // The result is T | None. Require one direct assignment per branch and
1815
+ // exactly the two proving conjuncts; extra control flow must abstain.
1816
+ const nullableBranchJoinType = (assignment, targetName, source) => {
1817
+ if (!assignment || !source ||
1818
+ !['identifier', 'attribute'].includes(source.type)) return null;
1819
+ const block = assignment.parent?.parent;
1820
+ const elseClause = block?.parent;
1821
+ const statement = elseClause?.parent;
1822
+ if (block?.type !== 'block' || block.namedChildCount !== 1 ||
1823
+ elseClause?.type !== 'else_clause' ||
1824
+ statement?.type !== 'if_statement') return null;
1825
+
1826
+ const directAssignment = branch => {
1827
+ if (branch?.type !== 'block' || branch.namedChildCount !== 1) {
1828
+ return null;
1829
+ }
1830
+ const expression = branch.namedChild(0);
1831
+ const candidate = expression?.type === 'expression_statement'
1832
+ ? expression.namedChild(0) : null;
1833
+ return candidate?.type === 'assignment' &&
1834
+ candidate.childForFieldName('left')?.type === 'identifier' &&
1835
+ candidate.childForFieldName('left').text === targetName
1836
+ ? candidate.childForFieldName('right') : null;
1837
+ };
1838
+ const alternative = elseClause.childForFieldName('body') ||
1839
+ elseClause.namedChild(0);
1840
+ const alternativeRhs = directAssignment(alternative);
1841
+ if (alternativeRhs?.id !== source.id) return null;
1842
+
1843
+ const condition = statement.childForFieldName('condition');
1844
+ const andTerms = node => {
1845
+ const isAnd = node?.type === 'boolean_operator' &&
1846
+ node.children.some(child => child.type === 'and');
1847
+ return isAnd
1848
+ ? node.namedChildren.flatMap(andTerms)
1849
+ : (node ? [node] : []);
1850
+ };
1851
+ const terms = andTerms(condition);
1852
+ if (terms.length !== 2) return null;
1853
+ const sameSource = node => node?.type === source.type &&
1854
+ node.text === source.text;
1855
+ const nonNull = terms.find(term => {
1856
+ if (term.type !== 'comparison_operator' ||
1857
+ !term.children.some(child => child.type === 'is not') ||
1858
+ term.namedChildCount !== 2) return false;
1859
+ const parts = [term.namedChild(0), term.namedChild(1)];
1860
+ return parts.some(part => part?.type === 'none') &&
1861
+ parts.some(sameSource);
1862
+ });
1863
+ const negativeIsinstance = terms.find(term =>
1864
+ term.type === 'not_operator' &&
1865
+ term.namedChild(0)?.type === 'call');
1866
+ if (!nonNull || !negativeIsinstance) return null;
1867
+ const check = negativeIsinstance.namedChild(0);
1868
+ if (check.childForFieldName('function')?.type !== 'identifier' ||
1869
+ check.childForFieldName('function').text !== 'isinstance') return null;
1870
+ const args = check.childForFieldName('arguments');
1871
+ if (!args || args.namedChildCount !== 2 ||
1872
+ !sameSource(args.namedChild(0))) return null;
1873
+ const narrowedType = typeNameFromExpr(args.namedChild(1));
1874
+ if (!narrowedType) return null;
1875
+
1876
+ const consequence = statement.childForFieldName('consequence');
1877
+ const consequenceRhs = directAssignment(consequence);
1878
+ if (consequenceRhs?.type !== 'call') return null;
1879
+ const constructor = exactConstructorInfo(
1880
+ consequenceRhs.childForFieldName('function'));
1881
+ return constructor?.type === narrowedType
1882
+ ? [narrowedType, 'None'] : null;
1883
+ };
1884
+
1885
+ // A function may legally reference a module global declared later in the
1886
+ // file. The source-order walk cannot see that binding yet, so collect the
1887
+ // narrow compiler-stable subset up front: one direct module assignment to
1888
+ // a bare constructor, with no other module-scope write to the name. Keep
1889
+ // qualified constructors out of this forward pass because their module
1890
+ // import must itself be proven at the assignment position.
1891
+ const moduleBindingCounts = new Map();
1892
+ const forwardModuleConstructorCandidates = new Map();
1893
+ const moduleTargetNames = target => {
1894
+ if (!target) return [];
1895
+ if (target.type === 'identifier') return [target.text];
1896
+ if (!['tuple', 'pattern_list', 'list', 'list_pattern',
1897
+ 'tuple_pattern', 'list_splat_pattern',
1898
+ 'dictionary_splat_pattern'].includes(target.type)) return [];
1899
+ const names = [];
1900
+ for (let i = 0; i < target.namedChildCount; i++) {
1901
+ names.push(...moduleTargetNames(target.namedChild(i)));
1902
+ }
1903
+ return names;
1904
+ };
1905
+ const isModuleScopeNode = node => {
1906
+ for (let parent = node?.parent; parent; parent = parent.parent) {
1907
+ if (parent.type === 'function_definition' ||
1908
+ parent.type === 'class_definition' ||
1909
+ parent.type === 'lambda') return false;
1910
+ }
1911
+ return true;
1912
+ };
1913
+ const moduleBindingNodeTypes = new Set([
1914
+ 'assignment', 'augmented_assignment', 'named_expression',
1915
+ 'for_statement', 'function_definition', 'async_function_definition',
1916
+ 'class_definition', 'import_statement', 'import_from_statement',
1917
+ 'global_statement', 'delete_statement', 'as_pattern',
1918
+ ]);
1919
+ let hasModuleWildcardImport = false;
1920
+ traverseTreeCached(tree.rootNode, node => {
1921
+ if (!moduleBindingNodeTypes.has(node.type)) return true;
1922
+ if (node.type === 'global_statement') {
1923
+ for (let i = 0; i < node.namedChildCount; i++) {
1924
+ const child = node.namedChild(i);
1925
+ if (child.type !== 'identifier') continue;
1926
+ moduleBindingCounts.set(child.text,
1927
+ (moduleBindingCounts.get(child.text) || 0) + 1);
1928
+ }
1929
+ return true;
1930
+ }
1931
+ if (!isModuleScopeNode(node)) return true;
1932
+ let names = [];
1933
+ if (node.type === 'assignment' ||
1934
+ node.type === 'augmented_assignment' ||
1935
+ node.type === 'named_expression') {
1936
+ names = moduleTargetNames(
1937
+ node.childForFieldName('left') ||
1938
+ node.childForFieldName('name'));
1939
+ } else if (node.type === 'for_statement') {
1940
+ names = moduleTargetNames(node.childForFieldName('left'));
1941
+ } else if (node.type === 'function_definition' ||
1942
+ node.type === 'async_function_definition' ||
1943
+ node.type === 'class_definition') {
1944
+ names = moduleTargetNames(node.childForFieldName('name'));
1945
+ } else if (node.type === 'import_statement') {
1946
+ for (let i = 0; i < node.namedChildCount; i++) {
1947
+ const child = node.namedChild(i);
1948
+ if (child.type === 'aliased_import') {
1949
+ const alias = child.childForFieldName('alias');
1950
+ const imported = child.childForFieldName('name');
1951
+ if (alias) names.push(alias.text);
1952
+ else if (imported) names.push(imported.text.split('.')[0]);
1953
+ } else if (child.type === 'dotted_name') {
1954
+ names.push(child.text.split('.')[0]);
1955
+ }
1956
+ }
1957
+ } else if (node.type === 'import_from_statement') {
1958
+ for (let i = 0; i < node.namedChildCount; i++) {
1959
+ const child = node.namedChild(i);
1960
+ if (child.type === 'wildcard_import') {
1961
+ hasModuleWildcardImport = true;
1962
+ } else if (child.type === 'aliased_import') {
1963
+ const alias = child.childForFieldName('alias');
1964
+ const imported = child.childForFieldName('name');
1965
+ if (alias) names.push(alias.text);
1966
+ else if (imported) names.push(imported.text.split('.').pop());
1967
+ } else if (i > 0 && child.type === 'dotted_name') {
1968
+ names.push(child.text.split('.').pop());
1969
+ }
1970
+ }
1971
+ } else if (node.type === 'delete_statement') {
1972
+ for (let i = 0; i < node.namedChildCount; i++) {
1973
+ names.push(...moduleTargetNames(node.namedChild(i)));
1974
+ }
1975
+ } else if (node.type === 'as_pattern') {
1976
+ names = moduleTargetNames(
1977
+ node.childForFieldName('alias')?.namedChild(0));
1978
+ }
1979
+ for (const name of names) {
1980
+ moduleBindingCounts.set(name,
1981
+ (moduleBindingCounts.get(name) || 0) + 1);
1982
+ }
1983
+ if (node.type !== 'assignment' || names.length !== 1) return true;
1984
+ const statement = node.parent?.type === 'expression_statement'
1985
+ ? node.parent : node;
1986
+ if (statement.parent?.type !== 'module') return true;
1987
+ const right = node.childForFieldName('right');
1988
+ if (right?.type !== 'call') return true;
1989
+ const ctor = constructorTypeInfo(right.childForFieldName('function'));
1990
+ if (!ctor || ctor.qualifier ||
1991
+ isPythonConstructorValueShadowedAt(
1992
+ right.childForFieldName('function'), ctor.type)) return true;
1993
+ forwardModuleConstructorCandidates.set(names[0], ctor);
1994
+ return true;
1995
+ });
1996
+ const stableForwardModuleConstructors = new Map(
1997
+ [...forwardModuleConstructorCandidates].filter(([name]) =>
1998
+ !hasModuleWildcardImport && moduleBindingCounts.get(name) === 1));
1663
1999
 
1664
2000
  traverseTree(tree.rootNode, (node) => {
1665
2001
  // Track module-alias bindings: `import httpx` binds 'httpx' (a module),
@@ -1672,6 +2008,7 @@ function findCallsInCode(code, parser) {
1672
2008
  if (child.type === 'dotted_name') {
1673
2009
  const first = child.namedChild(0);
1674
2010
  if (first?.type === 'identifier') moduleAliases.add(first.text);
2011
+ moduleImportSpecifiers.add(child.text);
1675
2012
  } else if (child.type === 'aliased_import') {
1676
2013
  const alias = child.childForFieldName('alias');
1677
2014
  if (alias?.type === 'identifier') moduleAliases.add(alias.text);
@@ -1708,10 +2045,47 @@ function findCallsInCode(code, parser) {
1708
2045
  localDictValueTypesStack.push(new Map(
1709
2046
  [...localDictValueTypes].map(([name, values]) =>
1710
2047
  [name, new Map(values)])));
2048
+ localSubscriptSourcesStack.push(new Map(localSubscriptSources));
1711
2049
  localVarStdlibContractsStack.push(new Map(localVarStdlibContracts));
1712
2050
  constructedReceiverVarsStack.push(new Set(constructedReceiverVars));
1713
2051
  withBindingVarsStack.push(new Set(withBindingVars));
1714
2052
  memberAliasesStack.push(new Map(memberAliases));
2053
+ classValueAliasesStack.push(new Map(classValueAliases));
2054
+ aliasesStack.push(new Map(aliases));
2055
+ nonCallableNamesStack.push(new Set(nonCallableNames));
2056
+ const body = node.childForFieldName('body');
2057
+ for (const [name, ctor] of stableForwardModuleConstructors) {
2058
+ if (functionParameterBindsName(node, name) ||
2059
+ (body && pythonScopeBindsName(body, name))) continue;
2060
+ if (!localVarTypes.has(name)) {
2061
+ localVarTypes.set(name, ctor.type);
2062
+ constructedReceiverVars.add(name);
2063
+ }
2064
+ }
2065
+ for (const name of aliases.keys()) {
2066
+ if ((body && pythonScopeBindsName(body, name)) ||
2067
+ functionParameterBindsName(node, name)) {
2068
+ aliases.delete(name);
2069
+ }
2070
+ }
2071
+ for (const name of nonCallableNames) {
2072
+ if ((body && pythonScopeBindsName(body, name)) ||
2073
+ functionParameterBindsName(node, name)) {
2074
+ nonCallableNames.delete(name);
2075
+ }
2076
+ }
2077
+ for (const name of localSubscriptSources.keys()) {
2078
+ if ((body && pythonScopeBindsName(body, name)) ||
2079
+ functionParameterBindsName(node, name)) {
2080
+ localSubscriptSources.delete(name);
2081
+ }
2082
+ }
2083
+ for (const name of classValueAliases.keys()) {
2084
+ if ((body && pythonScopeBindsName(body, name)) ||
2085
+ functionParameterBindsName(node, name)) {
2086
+ classValueAliases.delete(name);
2087
+ }
2088
+ }
1715
2089
  }
1716
2090
 
1717
2091
  // Track parameter type annotations: def foo(x: Foo) → x is Foo
@@ -1826,6 +2200,8 @@ function findCallsInCode(code, parser) {
1826
2200
  const left = node.childForFieldName('left');
1827
2201
  const right = node.childForFieldName('right');
1828
2202
  if (left?.type === 'identifier') {
2203
+ classValueAliases.delete(left.text);
2204
+ localSubscriptSources.delete(left.text);
1829
2205
  const previousType = localVarTypes.get(left.text);
1830
2206
  if (previousType && right?.type === 'call') {
1831
2207
  const rightFunction = right.childForFieldName('function');
@@ -1837,6 +2213,8 @@ function findCallsInCode(code, parser) {
1837
2213
  }
1838
2214
  // Track type annotation: x: Foo = ... → x is Foo
1839
2215
  const typeNode = node.childForFieldName('type');
2216
+ const branchJoinUnion = !typeNode
2217
+ ? nullableBranchJoinType(node, left.text, right) : null;
1840
2218
  if (typeNode) {
1841
2219
  const typeName = typeNameFromAnnotation(typeNode);
1842
2220
  const unionTypes = typeNamesFromAnnotation(typeNode);
@@ -1865,6 +2243,9 @@ function findCallsInCode(code, parser) {
1865
2243
  localIterationSources.delete(left.text);
1866
2244
  localDictValueTypes.delete(left.text);
1867
2245
  localVarStdlibContracts.delete(left.text);
2246
+ if (branchJoinUnion) {
2247
+ localVarUnionTypes.set(left.text, branchJoinUnion);
2248
+ }
1868
2249
  // Python assignments remain constrained by a variable or
1869
2250
  // parameter annotation. Constructor/literal inference is
1870
2251
  // nearest-assignment only, but a declared contract is
@@ -1944,6 +2325,20 @@ function findCallsInCode(code, parser) {
1944
2325
  localDictValueTypes.set(left.text, valueTypes);
1945
2326
  }
1946
2327
  }
2328
+ if (!typeNode && right?.type === 'subscript') {
2329
+ const base = right.childForFieldName('value');
2330
+ if (base?.type === 'identifier' && base.text !== left.text) {
2331
+ const rootType = localVarTypes.get(base.text);
2332
+ if (rootType) {
2333
+ localSubscriptSources.set(left.text, {
2334
+ root: base.text,
2335
+ rootType,
2336
+ rootTypeQualifier:
2337
+ localVarTypeQualifiers.get(base.text),
2338
+ });
2339
+ }
2340
+ }
2341
+ }
1947
2342
  const roundTripSource = pickleRoundTripSource(right);
1948
2343
  const roundTripType = roundTripSource
1949
2344
  ? localVarTypes.get(roundTripSource) : null;
@@ -1953,6 +2348,11 @@ function findCallsInCode(code, parser) {
1953
2348
  }
1954
2349
  if (right?.type === 'identifier') {
1955
2350
  aliases.set(left.text, right.text);
2351
+ const classValue = !typeNode && isDirectScopeAssignment(node)
2352
+ ? exactConstructorInfo(right) : null;
2353
+ if (classValue) {
2354
+ classValueAliases.set(left.text, classValue.type);
2355
+ }
1956
2356
  }
1957
2357
  // Member-access alias (fix #218): append = output.append
1958
2358
  else if (right?.type === 'attribute') {
@@ -2197,13 +2597,31 @@ function findCallsInCode(code, parser) {
2197
2597
  // Literal receivers carry their builtin type: {}.get() can
2198
2598
  // never be a project class method
2199
2599
  let subscriptReceiverType;
2600
+ let receiverSubscriptRoot;
2601
+ let receiverSubscriptRootType;
2602
+ let receiverSubscriptRootTypeQualifier;
2200
2603
  if (objNode?.type === 'subscript') {
2201
2604
  const base = objNode.childForFieldName('value');
2202
2605
  const key = literalStringValue(
2203
2606
  objNode.childForFieldName('subscript'));
2204
- if (base?.type === 'identifier' && key != null) {
2205
- subscriptReceiverType =
2206
- localDictValueTypes.get(base.text)?.get(key);
2607
+ if (base?.type === 'identifier') {
2608
+ receiverSubscriptRoot = base.text;
2609
+ receiverSubscriptRootType = localVarTypes.get(base.text);
2610
+ receiverSubscriptRootTypeQualifier =
2611
+ localVarTypeQualifiers.get(base.text);
2612
+ if (key != null) {
2613
+ subscriptReceiverType =
2614
+ localDictValueTypes.get(base.text)?.get(key);
2615
+ }
2616
+ }
2617
+ }
2618
+ if (!receiverSubscriptRoot && receiver) {
2619
+ const source = localSubscriptSources.get(receiver);
2620
+ if (source) {
2621
+ receiverSubscriptRoot = source.root;
2622
+ receiverSubscriptRootType = source.rootType;
2623
+ receiverSubscriptRootTypeQualifier =
2624
+ source.rootTypeQualifier;
2207
2625
  }
2208
2626
  }
2209
2627
  const receiverType = receiver
@@ -2212,7 +2630,8 @@ function findCallsInCode(code, parser) {
2212
2630
  comprehensionReceiverType(
2213
2631
  objNode, receiver, localIterableTypes,
2214
2632
  callableIterableTypes, instanceFieldContracts) ||
2215
- localVarTypes.get(receiver))
2633
+ localVarTypes.get(receiver) ||
2634
+ classValueAliases.get(receiver))
2216
2635
  || assignmentRhsReceiverTypes.get(node.id)
2217
2636
  : (subscriptReceiverType ||
2218
2637
  (objNode ? PY_LITERAL_RECEIVER_TYPES[objNode.type] : undefined));
@@ -2246,6 +2665,13 @@ function findCallsInCode(code, parser) {
2246
2665
  : undefined;
2247
2666
  const receiverRootType = receiverPath
2248
2667
  ? localVarTypes.get(receiverPath.root) : undefined;
2668
+ const dottedReceiver = receiverPath
2669
+ ? [receiverPath.root, ...receiverPath.fields].join('.')
2670
+ : null;
2671
+ const receiverModuleSpecifier = dottedReceiver &&
2672
+ moduleImportSpecifiers.has(dottedReceiver) &&
2673
+ !isShadowedByLocal(objNode, receiverPath.root)
2674
+ ? dottedReceiver : undefined;
2249
2675
  // Module receiver (httpx.get()) — unless locally shadowed
2250
2676
  // by a typed instance binding
2251
2677
  const receiverIsModule = !!receiver && moduleAliases.has(receiver) &&
@@ -2270,8 +2696,20 @@ function findCallsInCode(code, parser) {
2270
2696
  ...(receiver && constructedReceiverVars.has(receiver) && { receiverConstructed: true }),
2271
2697
  ...(receiver && withBindingVars.has(receiver) && { receiverWithBinding: true }),
2272
2698
  ...(receiverIsModule && { receiverIsModule: true }),
2699
+ ...(receiverModuleSpecifier && { receiverModuleSpecifier }),
2700
+ ...(receiverSubscriptRoot && { receiverSubscriptRoot }),
2701
+ ...(receiverSubscriptRootType && {
2702
+ receiverSubscriptRootType,
2703
+ }),
2704
+ ...(receiverSubscriptRootTypeQualifier && {
2705
+ receiverSubscriptRootTypeQualifier,
2706
+ }),
2273
2707
  ...(receiver && objNode?.type === 'identifier' &&
2274
2708
  isShadowedByLocal(objNode, receiver) && { receiverLocalBinding: true }),
2709
+ ...(receiver && !receiverType && objNode?.type === 'identifier' &&
2710
+ isPythonUnprovenIterationBindingAt(objNode, receiver) && {
2711
+ receiverUntypedIteration: true,
2712
+ }),
2275
2713
  ...(receiverPath && {
2276
2714
  receiverRoot: receiverPath.root,
2277
2715
  receiverField: receiverPath.fields[receiverPath.fields.length - 1],
@@ -2432,6 +2870,13 @@ function findCallsInCode(code, parser) {
2432
2870
  localDictValueTypes.set(name, values);
2433
2871
  }
2434
2872
  }
2873
+ const savedSubscriptSources = localSubscriptSourcesStack.pop();
2874
+ if (savedSubscriptSources) {
2875
+ localSubscriptSources.clear();
2876
+ for (const [name, source] of savedSubscriptSources) {
2877
+ localSubscriptSources.set(name, source);
2878
+ }
2879
+ }
2435
2880
  const savedStdlibContracts = localVarStdlibContractsStack.pop();
2436
2881
  if (savedStdlibContracts) {
2437
2882
  localVarStdlibContracts.clear();
@@ -2450,6 +2895,25 @@ function findCallsInCode(code, parser) {
2450
2895
  memberAliases.clear();
2451
2896
  for (const [k, v] of savedAliases) memberAliases.set(k, v);
2452
2897
  }
2898
+ const savedClassValueAliases = classValueAliasesStack.pop();
2899
+ if (savedClassValueAliases) {
2900
+ classValueAliases.clear();
2901
+ for (const [k, v] of savedClassValueAliases) {
2902
+ classValueAliases.set(k, v);
2903
+ }
2904
+ }
2905
+ const savedCallableAliases = aliasesStack.pop();
2906
+ if (savedCallableAliases) {
2907
+ aliases.clear();
2908
+ for (const [k, v] of savedCallableAliases) aliases.set(k, v);
2909
+ }
2910
+ const savedNonCallableNames = nonCallableNamesStack.pop();
2911
+ if (savedNonCallableNames) {
2912
+ nonCallableNames.clear();
2913
+ for (const name of savedNonCallableNames) {
2914
+ nonCallableNames.add(name);
2915
+ }
2916
+ }
2453
2917
  }
2454
2918
  }
2455
2919
  });
@@ -2472,20 +2936,123 @@ function findImportsInCode(code, parser) {
2472
2936
  // module initialization. Preserve that AST fact so dependency-cycle
2473
2937
  // reporting can distinguish an eager import loop from a deliberate lazy
2474
2938
  // edge without deleting either edge from the graph.
2475
- const isDeferredImport = (node) => {
2939
+ // fix #338: `if TYPE_CHECKING:` (bare, `t.TYPE_CHECKING`,
2940
+ // `typing.TYPE_CHECKING`) consequence blocks never execute at runtime —
2941
+ // the import exists for the type checker only. Only the consequence
2942
+ // branch is guarded: `else:` and `if not TYPE_CHECKING:` bodies DO run.
2943
+ let typingBindings = null;
2944
+ let typingWildcard = false;
2945
+ const isNestedScope = node => {
2946
+ for (let p = node.parent; p; p = p.parent) {
2947
+ if (p.type === 'function_definition' || p.type === 'class_definition' || p.type === 'lambda') return true;
2948
+ }
2949
+ return false;
2950
+ };
2951
+ // Which local names prove a runtime-false guard, and where. A guard at
2952
+ // module level reads the module binding: only module-level rebindings
2953
+ // (assignments, loops, def/class names, walrus, `as` targets) and a
2954
+ // second import of the same name can disturb it — a function parameter
2955
+ // or a nested import lives in its own scope. A guard INSIDE a function
2956
+ // can additionally be shadowed by that function's locals, so nested
2957
+ // rebindings poison nested guards (conservative: any nested scope, not
2958
+ // just the enclosing one). Same-spelled user flags/attributes can be
2959
+ // true; only a unique `typing` binding proves the guard false.
2960
+ const typingBindingKind = (name, guardNested) => {
2961
+ if (!typingBindings) {
2962
+ typingBindings = new Map();
2963
+ const entry = local => {
2964
+ let e = typingBindings.get(local);
2965
+ if (!e) { e = { kind: null, imports: 0, moduleShadow: false, nestedShadow: false }; typingBindings.set(local, e); }
2966
+ return e;
2967
+ };
2968
+ const shadow = (pattern, nested) => {
2969
+ if (!pattern) return;
2970
+ traverseTree(pattern, child => {
2971
+ if (child.type === 'identifier') {
2972
+ const e = entry(child.text);
2973
+ if (nested) e.nestedShadow = true; else e.moduleShadow = true;
2974
+ }
2975
+ return true;
2976
+ });
2977
+ };
2978
+ traverseTree(tree.rootNode, n => {
2979
+ const nested = isNestedScope(n);
2980
+ if (n.type === 'import_statement' || n.type === 'import_from_statement') {
2981
+ if (n.namedChildren.some(item => item.type === 'wildcard_import')) typingWildcard = true;
2982
+ const moduleNode = n.childForFieldName('module_name');
2983
+ for (const item of n.namedChildren) {
2984
+ if (moduleNode && sameNode(moduleNode, item)) continue;
2985
+ const imported = item.type === 'aliased_import' ? item.childForFieldName('name')?.text : item.text;
2986
+ const local = item.type === 'aliased_import' ? item.childForFieldName('alias')?.text : imported?.split('.')[0];
2987
+ if (!local) continue;
2988
+ const e = entry(local);
2989
+ if (nested) { e.nestedShadow = true; continue; }
2990
+ const kind = n.type === 'import_statement'
2991
+ ? (imported === 'typing' ? 'module' : null)
2992
+ : (moduleNode?.text === 'typing' && imported === 'TYPE_CHECKING' ? 'flag' : null);
2993
+ e.imports++;
2994
+ e.kind = e.imports === 1 ? kind : null;
2995
+ }
2996
+ } else if (n.type === 'assignment' || n.type === 'augmented_assignment' || n.type === 'for_statement' || n.type === 'for_in_clause') {
2997
+ shadow(n.childForFieldName('left'), nested);
2998
+ } else if (n.type === 'parameters' || n.type === 'lambda_parameters') {
2999
+ // Parameter NAMES rebind; annotations (`x: t.Any`) and
3000
+ // default values are expressions that READ the outer
3001
+ // binding. Walking the whole subtree treated every
3002
+ // `t.`-annotated parameter as a rebinding of `t` and
3003
+ // reverted click's TYPE_CHECKING guards to eager.
3004
+ for (const param of n.namedChildren) {
3005
+ if (param.type === 'default_parameter' || param.type === 'typed_default_parameter') {
3006
+ shadow(param.childForFieldName('name'), true);
3007
+ } else if (param.type === 'typed_parameter') {
3008
+ shadow(param.namedChild(0), true);
3009
+ } else if (param.type !== 'keyword_separator' && param.type !== 'positional_separator') {
3010
+ shadow(param, true);
3011
+ }
3012
+ }
3013
+ } else if (n.type === 'named_expression') shadow(n.childForFieldName('name'), nested);
3014
+ else if (n.type === 'function_definition' || n.type === 'class_definition') shadow(n.childForFieldName('name'), nested);
3015
+ else if (n.type === 'as_pattern') shadow(n.childForFieldName('alias'), nested);
3016
+ return true;
3017
+ });
3018
+ }
3019
+ if (typingWildcard) return null;
3020
+ const e = typingBindings.get(name);
3021
+ if (!e || e.imports !== 1 || e.moduleShadow) return null;
3022
+ if (guardNested && e.nestedShadow) return null;
3023
+ return e.kind;
3024
+ };
3025
+ const isTypeCheckingGuard = (condition, guardNested = true) => {
3026
+ if (!condition) return false;
3027
+ if (condition.type === 'parenthesized_expression') return isTypeCheckingGuard(condition.namedChild(0), guardNested);
3028
+ if (condition.type === 'identifier') return typingBindingKind(condition.text, guardNested) === 'flag';
3029
+ if (condition.type === 'attribute') {
3030
+ const object = condition.childForFieldName('object');
3031
+ return condition.childForFieldName('attribute')?.text === 'TYPE_CHECKING' &&
3032
+ object?.type === 'identifier' && typingBindingKind(object.text, guardNested) === 'module';
3033
+ }
3034
+ return false;
3035
+ };
3036
+ const importDeferral = (node) => {
2476
3037
  for (let parent = node.parent; parent; parent = parent.parent) {
2477
3038
  if (parent.type === 'function_definition' || parent.type === 'lambda') {
2478
- return true;
3039
+ return 'function-local';
3040
+ }
3041
+ if (parent.type === 'block' && parent.parent &&
3042
+ (parent.parent.type === 'if_statement' || parent.parent.type === 'elif_clause') &&
3043
+ sameNode(parent.parent.childForFieldName('consequence'), parent) &&
3044
+ isTypeCheckingGuard(parent.parent.childForFieldName('condition'), isNestedScope(parent.parent))) {
3045
+ return 'type-checking';
2479
3046
  }
2480
3047
  }
2481
- return false;
3048
+ return null;
2482
3049
  };
2483
3050
 
2484
3051
  traverseTreeCached(tree.rootNode, (node) => {
2485
3052
  // import statement: import os, import sys as system
2486
3053
  if (node.type === 'import_statement') {
2487
3054
  const line = node.startPosition.row + 1;
2488
- const deferred = isDeferredImport(node);
3055
+ const deferral = importDeferral(node);
2489
3056
 
2490
3057
  for (let i = 0; i < node.namedChildCount; i++) {
2491
3058
  const child = node.namedChild(i);
@@ -2502,14 +3069,14 @@ function findImportsInCode(code, parser) {
2502
3069
  names: [parts[0]],
2503
3070
  type: 'import',
2504
3071
  line,
2505
- ...(deferred && { deferred: true })
3072
+ ...(deferral && { deferred: true, deferredReason: deferral })
2506
3073
  });
2507
3074
  imports.push({
2508
3075
  module: child.text,
2509
3076
  names: [],
2510
3077
  type: 'import-submodule',
2511
3078
  line,
2512
- ...(deferred && { deferred: true })
3079
+ ...(deferral && { deferred: true, deferredReason: deferral })
2513
3080
  });
2514
3081
  } else {
2515
3082
  imports.push({
@@ -2517,7 +3084,7 @@ function findImportsInCode(code, parser) {
2517
3084
  names: [child.text],
2518
3085
  type: 'import',
2519
3086
  line,
2520
- ...(deferred && { deferred: true })
3087
+ ...(deferral && { deferred: true, deferredReason: deferral })
2521
3088
  });
2522
3089
  }
2523
3090
  } else if (child.type === 'aliased_import') {
@@ -2530,7 +3097,7 @@ function findImportsInCode(code, parser) {
2530
3097
  names: [aliasNode ? aliasNode.text : nameNode.text.split('.').pop()],
2531
3098
  type: 'import',
2532
3099
  line,
2533
- ...(deferred && { deferred: true })
3100
+ ...(deferral && { deferred: true, deferredReason: deferral })
2534
3101
  });
2535
3102
  if (aliasNode && aliasNode.text !== nameNode.text) {
2536
3103
  if (!importAliases) importAliases = [];
@@ -2545,7 +3112,7 @@ function findImportsInCode(code, parser) {
2545
3112
  // from ... import statement
2546
3113
  if (node.type === 'import_from_statement') {
2547
3114
  const line = node.startPosition.row + 1;
2548
- const deferred = isDeferredImport(node);
3115
+ const deferral = importDeferral(node);
2549
3116
  let modulePath = '';
2550
3117
  const names = [];
2551
3118
 
@@ -2579,7 +3146,7 @@ function findImportsInCode(code, parser) {
2579
3146
  names,
2580
3147
  type: isRelative ? 'relative' : 'from',
2581
3148
  line,
2582
- ...(deferred && { deferred: true })
3149
+ ...(deferral && { deferred: true, deferredReason: deferral })
2583
3150
  });
2584
3151
  }
2585
3152
  return true;
@@ -2594,7 +3161,7 @@ function findImportsInCode(code, parser) {
2594
3161
  const firstArg = argsNode.namedChild(0);
2595
3162
  if ((funcName === 'importlib.import_module' || funcName === '__import__') && firstArg) {
2596
3163
  const line = node.startPosition.row + 1;
2597
- const deferred = isDeferredImport(node);
3164
+ const deferral = importDeferral(node);
2598
3165
  const isLiteral = firstArg.type === 'string';
2599
3166
  imports.push({
2600
3167
  module: isLiteral ? firstArg.text.replace(/^['"]|['"]$/g, '') : firstArg.text,
@@ -2602,7 +3169,7 @@ function findImportsInCode(code, parser) {
2602
3169
  type: 'dynamic',
2603
3170
  line,
2604
3171
  dynamic: !isLiteral,
2605
- ...(deferred && { deferred: true })
3172
+ ...(deferral && { deferred: true, deferredReason: deferral })
2606
3173
  });
2607
3174
  }
2608
3175
  }
@@ -2834,6 +3401,50 @@ function findInstanceAttributeTypes(code, parser, options = {}) {
2834
3401
  const explicitContracts = explicitInstanceFieldContracts(tree, parser);
2835
3402
 
2836
3403
  const PRIMITIVE_TYPES = new Set(['int', 'float', 'str', 'bool', 'bytes', 'list', 'dict', 'set', 'tuple', 'None', 'Any', 'object']);
3404
+ const conditionalFieldType = (node, parameterAlternatives) => {
3405
+ let current = node;
3406
+ while (current?.type === 'parenthesized_expression' &&
3407
+ current.namedChildCount === 1) {
3408
+ current = current.namedChild(0);
3409
+ }
3410
+ if (current?.type !== 'conditional_expression' ||
3411
+ current.namedChildCount < 3) return null;
3412
+ const consequence = current.namedChild(0);
3413
+ const condition = current.namedChild(1);
3414
+ const alternative = current.namedChild(2);
3415
+ const parameterBranches = [consequence, alternative]
3416
+ .map((branch, index) => branch?.type === 'identifier' &&
3417
+ parameterAlternatives.has(branch.text)
3418
+ ? { index, name: branch.text } : null)
3419
+ .filter(Boolean);
3420
+ if (parameterBranches.length !== 1) return null;
3421
+ const parameterBranch = parameterBranches[0];
3422
+ const positiveTypes = isinstanceTypes(condition, parameterBranch.name);
3423
+ if (positiveTypes.length === 0) return null;
3424
+ const declaredTypes = parameterAlternatives.get(parameterBranch.name);
3425
+ const narrowedTypes = parameterBranch.index === 0
3426
+ ? declaredTypes.filter(type => positiveTypes.includes(type))
3427
+ : declaredTypes.filter(type => !positiveTypes.includes(type));
3428
+ if (narrowedTypes.length !== 1) return null;
3429
+
3430
+ const valueBranch = parameterBranch.index === 0
3431
+ ? alternative : consequence;
3432
+ if (valueBranch?.type !== 'call') return null;
3433
+ const callable = valueBranch.childForFieldName('function');
3434
+ let valueType = null;
3435
+ if (callable?.type === 'identifier' && /^[A-Z]/.test(callable.text) &&
3436
+ !isPythonConstructorValueShadowedAt(callable, callable.text)) {
3437
+ valueType = callable.text;
3438
+ } else if (callable?.type === 'attribute') {
3439
+ const owner = callable.childForFieldName('object');
3440
+ const method = callable.childForFieldName('attribute');
3441
+ if (owner?.type === 'identifier' && method?.type === 'identifier' &&
3442
+ !isPythonNameShadowedAt(callable, owner.text)) {
3443
+ valueType = options.resolveCallType?.(owner.text, method.text);
3444
+ }
3445
+ }
3446
+ return valueType === narrowedTypes[0] ? valueType : null;
3447
+ };
2837
3448
 
2838
3449
  traverseTreeCached(tree.rootNode, (node) => {
2839
3450
  if (node.type !== 'class_definition') return true;
@@ -2888,10 +3499,14 @@ function findInstanceAttributeTypes(code, parser, options = {}) {
2888
3499
  if (!typeNode) continue;
2889
3500
  if (!isDataclass && assign.childForFieldName('right')) continue;
2890
3501
 
2891
- // Extract type name from annotation
2892
- const typeIdent = typeNode.type === 'type' ? typeNode.firstChild : typeNode;
2893
- if (!typeIdent || typeIdent.type !== 'identifier') continue;
2894
- const typeName = typeIdent.text;
3502
+ // Use the same single-type annotation parser as parameters and
3503
+ // explicit instance fields. This admits compiler-equivalent bare
3504
+ // contracts such as `color: Optional[Color]` and `Color | None`,
3505
+ // while unions with several value types still abstain. Qualified
3506
+ // names need origin metadata that this compact attr map does not
3507
+ // retain, so leave those to declared field symbols.
3508
+ const typeName = typeNameFromAnnotation(typeNode);
3509
+ if (!typeName || typeQualifierFromAnnotation(typeNode)) continue;
2895
3510
 
2896
3511
  // Skip primitives and lowercase types
2897
3512
  if (PRIMITIVE_TYPES.has(typeName)) continue;
@@ -2927,6 +3542,7 @@ function findInstanceAttributeTypes(code, parser, options = {}) {
2927
3542
  // Build parameter type map from __init__ annotations
2928
3543
  // e.g. def __init__(self, market: MarketDataFetcher = None) → {market: MarketDataFetcher}
2929
3544
  const paramTypes = new Map();
3545
+ const paramAlternatives = new Map();
2930
3546
  const params = child.childForFieldName('parameters');
2931
3547
  if (params) {
2932
3548
  for (let p = 0; p < params.childCount; p++) {
@@ -2936,12 +3552,25 @@ function findInstanceAttributeTypes(code, parser, options = {}) {
2936
3552
  const pName = param.childForFieldName('name') || param.child(0);
2937
3553
  const pType = param.childForFieldName('type');
2938
3554
  if (pName && pType) {
2939
- const typeIdent = pType.type === 'type' ? pType.firstChild : pType;
2940
- if (typeIdent?.type === 'identifier') {
2941
- const tn = typeIdent.text;
2942
- if (!PRIMITIVE_TYPES.has(tn) && tn[0] >= 'A' && tn[0] <= 'Z') {
2943
- paramTypes.set(pName.text, tn);
2944
- }
3555
+ // Use the shared annotation parser so forward
3556
+ // references (`"Environment"`), dotted names,
3557
+ // and Optional/union wrappers participate in the
3558
+ // same conservative receiver-type contract as
3559
+ // ordinary parameter calls.
3560
+ const tn = typeNameFromAnnotation(pType);
3561
+ const directAlternatives = typeNamesFromAnnotation(pType);
3562
+ const aliasAlternatives = directAlternatives.length === 1
3563
+ ? options.resolveTypeAliasMembers?.(
3564
+ directAlternatives[0])
3565
+ : null;
3566
+ const alternatives = aliasAlternatives?.length > 1
3567
+ ? aliasAlternatives : directAlternatives;
3568
+ if (alternatives.length > 1) {
3569
+ paramAlternatives.set(pName.text, alternatives);
3570
+ }
3571
+ if (tn && !PRIMITIVE_TYPES.has(tn) &&
3572
+ tn[0] >= 'A' && tn[0] <= 'Z') {
3573
+ paramTypes.set(pName.text, tn);
2945
3574
  }
2946
3575
  }
2947
3576
  }
@@ -2967,7 +3596,8 @@ function findInstanceAttributeTypes(code, parser, options = {}) {
2967
3596
  const rhs = assign.childForFieldName('right');
2968
3597
  if (!rhs) return true;
2969
3598
 
2970
- const typeName = extractConstructorName(rhs);
3599
+ const typeName = conditionalFieldType(rhs, paramAlternatives) ||
3600
+ extractConstructorName(rhs);
2971
3601
  if (typeName) {
2972
3602
  attrTypes.set(attrName, typeName);
2973
3603
  } else if (rhs.type === 'call') {
@@ -3022,13 +3652,23 @@ function findInstanceAttributeTypes(code, parser, options = {}) {
3022
3652
  const right = assignment.childForFieldName('right');
3023
3653
  const callable = right?.type === 'call'
3024
3654
  ? right.childForFieldName('function') : null;
3025
- const moduleName = callable?.type === 'attribute'
3026
- ? callable.childForFieldName('object')?.text : null;
3655
+ const callableReceiver = callable?.type === 'attribute'
3656
+ ? callable.childForFieldName('object') : null;
3657
+ const directConstructor = callable?.type === 'identifier' &&
3658
+ /^[A-Z]/.test(callable.text) &&
3659
+ !isPythonConstructorValueShadowedAt(callable, callable.text)
3660
+ ? callable.text : null;
3661
+ const moduleName = callableReceiver?.type === 'identifier'
3662
+ ? callableReceiver.text : null;
3027
3663
  const functionName = callable?.type === 'attribute'
3028
3664
  ? callable.childForFieldName('attribute')?.text : null;
3029
- const runtimeType = moduleName && functionName
3030
- ? options.resolveBuiltinCallType?.(moduleName, functionName)
3031
- : null;
3665
+ const resolveCallType = options.resolveCallType ||
3666
+ options.resolveBuiltinCallType;
3667
+ const runtimeType = directConstructor ||
3668
+ (moduleName && functionName &&
3669
+ !isPythonNameShadowedAt(callable, moduleName)
3670
+ ? resolveCallType?.(moduleName, functionName)
3671
+ : null);
3032
3672
  if (!runtimeType) {
3033
3673
  // None is a harmless uninitialized state; any other
3034
3674
  // unknown write could replace the field with a project