ucn 5.1.1 → 5.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.
@@ -1076,6 +1076,35 @@ function assignmentTargetOf(callNode) {
1076
1076
  return undefined;
1077
1077
  }
1078
1078
 
1079
+ /**
1080
+ * For-loop iteration target of a call used as the iterable:
1081
+ * `for ep in entry_points(...)` / comprehension `for x in items()`.
1082
+ * The loop variable holds an ELEMENT of the producer's result, never the
1083
+ * return type itself — consumers must only use this for provenance
1084
+ * (external-producer demotion), never positive typing (fix #294).
1085
+ */
1086
+ function iterTargetOf(callNode) {
1087
+ let n = callNode;
1088
+ let p = n.parent;
1089
+ if (p && p.type === 'await') { n = p; p = n.parent; }
1090
+ if (!p || (p.type !== 'for_statement' && p.type !== 'for_in_clause')) return undefined;
1091
+ const right = p.childForFieldName('right');
1092
+ if (!right || right.id !== n.id) return undefined;
1093
+ const left = p.childForFieldName('left');
1094
+ if (!left) return undefined;
1095
+ const names = [];
1096
+ if (left.type === 'identifier') {
1097
+ names.push(left.text);
1098
+ } else if (left.type === 'pattern_list' || left.type === 'tuple_pattern') {
1099
+ for (let i = 0; i < left.namedChildCount; i++) {
1100
+ const c = left.namedChild(i);
1101
+ if (c.type === 'identifier') names.push(c.text);
1102
+ }
1103
+ }
1104
+ if (names.length === 0) return undefined;
1105
+ return { first: names[0], rest: names.slice(1) };
1106
+ }
1107
+
1079
1108
  function contextTargetOf(callNode) {
1080
1109
  const pattern = callNode?.parent;
1081
1110
  if (pattern?.type !== 'as_pattern' ||
@@ -1231,6 +1260,247 @@ function pickleRoundTripSource(node) {
1231
1260
  return value?.type === 'identifier' ? value.text : null;
1232
1261
  }
1233
1262
 
1263
+ function pythonTargetBindsName(left, name) {
1264
+ if (!left) return false;
1265
+ if (left.type === 'identifier' && left.text === name) return true;
1266
+ if (left.type === 'pattern_list' || left.type === 'tuple_pattern') {
1267
+ for (let i = 0; i < left.namedChildCount; i++) {
1268
+ if (left.namedChild(i).type === 'identifier' &&
1269
+ left.namedChild(i).text === name) return true;
1270
+ }
1271
+ }
1272
+ return false;
1273
+ }
1274
+
1275
+ function pythonScopeBindsName(scopeNode, name) {
1276
+ for (let i = 0; i < scopeNode.namedChildCount; i++) {
1277
+ const child = scopeNode.namedChild(i);
1278
+ if (child.type === 'function_definition' ||
1279
+ child.type === 'async_function_definition' ||
1280
+ child.type === 'class_definition') {
1281
+ // The nested body is a separate scope, but the declaration name
1282
+ // binds in this scope.
1283
+ if (child.childForFieldName('name')?.text === name) return true;
1284
+ continue;
1285
+ }
1286
+ if (child.type === 'lambda') continue;
1287
+ if (child.type === 'assignment' ||
1288
+ child.type === 'augmented_assignment' ||
1289
+ child.type === 'named_expression') {
1290
+ if (pythonTargetBindsName(
1291
+ child.childForFieldName('left') || child.childForFieldName('name'),
1292
+ name)) return true;
1293
+ } else if (child.type === 'for_statement') {
1294
+ if (pythonTargetBindsName(child.childForFieldName('left'), name)) return true;
1295
+ } else if (child.type === 'with_statement') {
1296
+ const text = child.namedChild(0)?.text || '';
1297
+ const match = text.match(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/);
1298
+ if (match && match[1] === name) return true;
1299
+ }
1300
+ if (pythonScopeBindsName(child, name)) return true;
1301
+ }
1302
+ return false;
1303
+ }
1304
+
1305
+ const PY_COMPREHENSIONS = new Set([
1306
+ 'generator_expression', 'list_comprehension', 'set_comprehension',
1307
+ 'dictionary_comprehension',
1308
+ ]);
1309
+
1310
+ // Python locals are function-scoped: an assignment anywhere in the function
1311
+ // shadows an imported module for every reference in that function. Keep this
1312
+ // shared between call records and reference records so plan cannot promote a
1313
+ // receiver that callers would reject as locally rebound.
1314
+ function isPythonNameShadowedAt(refNode, name) {
1315
+ for (let parent = refNode.parent; parent; parent = parent.parent) {
1316
+ if (PY_COMPREHENSIONS.has(parent.type)) {
1317
+ for (let i = 0; i < parent.namedChildCount; i++) {
1318
+ const clause = parent.namedChild(i);
1319
+ if (clause.type === 'for_in_clause' &&
1320
+ pythonTargetBindsName(clause.childForFieldName('left'), name)) {
1321
+ return true;
1322
+ }
1323
+ }
1324
+ }
1325
+ if (parent.type === 'lambda') {
1326
+ const params = parent.childForFieldName('parameters');
1327
+ if (params) for (let i = 0; i < params.namedChildCount; i++) {
1328
+ const param = params.namedChild(i);
1329
+ if (param.type === 'identifier' && param.text === name) return true;
1330
+ if (param.type === 'default_parameter' &&
1331
+ param.childForFieldName('name')?.text === name) return true;
1332
+ }
1333
+ }
1334
+ if (parent.type === 'function_definition' ||
1335
+ parent.type === 'async_function_definition') {
1336
+ const params = parent.childForFieldName('parameters');
1337
+ if (params) {
1338
+ for (let i = 0; i < params.namedChildCount; i++) {
1339
+ const param = params.namedChild(i);
1340
+ const paramName = param.type === 'identifier'
1341
+ ? param
1342
+ : (param.childForFieldName('name') || param.namedChild(0));
1343
+ if (paramName?.type === 'identifier' &&
1344
+ paramName.text === name) return true;
1345
+ }
1346
+ }
1347
+ const body = parent.childForFieldName('body');
1348
+ return body ? pythonScopeBindsName(body, name) : false;
1349
+ }
1350
+ }
1351
+ return false;
1352
+ }
1353
+
1354
+ /**
1355
+ * Whether an uppercase call target is a local VALUE binding rather than a
1356
+ * class declaration.
1357
+ *
1358
+ * Python permits runtime class factories and decorators to be rebound under
1359
+ * class-shaped names:
1360
+ *
1361
+ * C2 = attrs.define(...)(Base)
1362
+ * value = C2()
1363
+ *
1364
+ * The capitalization convention alone cannot type `value` as an indexed
1365
+ * `class C2` from another lexical scope. Scan the nearest Python scope using
1366
+ * AST bindings and reject constructor-name inference when a parameter,
1367
+ * function, assignment, loop, or with-target owns the name. A sole local
1368
+ * class declaration is deliberately allowed; its exact declaration is
1369
+ * selected later with the call site's enclosing-function range.
1370
+ */
1371
+ function isPythonConstructorValueShadowedAt(refNode, name) {
1372
+ let scope = null;
1373
+ for (let parent = refNode?.parent; parent; parent = parent.parent) {
1374
+ if (PY_COMPREHENSIONS.has(parent.type)) {
1375
+ for (let i = 0; i < parent.namedChildCount; i++) {
1376
+ const clause = parent.namedChild(i);
1377
+ if (clause.type === 'for_in_clause' &&
1378
+ pythonTargetBindsName(
1379
+ clause.childForFieldName('left'), name)) return true;
1380
+ }
1381
+ }
1382
+ if (parent.type === 'lambda') {
1383
+ const params = parent.childForFieldName('parameters');
1384
+ if (params) {
1385
+ for (let i = 0; i < params.namedChildCount; i++) {
1386
+ const param = params.namedChild(i);
1387
+ const paramName = param.type === 'identifier'
1388
+ ? param
1389
+ : (param.childForFieldName('name') || param.namedChild(0));
1390
+ if (paramName?.type === 'identifier' &&
1391
+ paramName.text === name) return true;
1392
+ }
1393
+ }
1394
+ scope = parent;
1395
+ break;
1396
+ }
1397
+ if (parent.type === 'function_definition' ||
1398
+ parent.type === 'async_function_definition') {
1399
+ const params = parent.childForFieldName('parameters');
1400
+ if (params) {
1401
+ for (let i = 0; i < params.namedChildCount; i++) {
1402
+ const param = params.namedChild(i);
1403
+ const paramName = param.type === 'identifier'
1404
+ ? param
1405
+ : (param.childForFieldName('name') || param.namedChild(0));
1406
+ if (paramName?.type === 'identifier' &&
1407
+ paramName.text === name) return true;
1408
+ }
1409
+ }
1410
+ scope = parent.childForFieldName('body');
1411
+ break;
1412
+ }
1413
+ if (parent.type === 'class_definition') {
1414
+ scope = parent.childForFieldName('body');
1415
+ break;
1416
+ }
1417
+ if (parent.type === 'module') {
1418
+ scope = parent;
1419
+ break;
1420
+ }
1421
+ }
1422
+ if (!scope) return false;
1423
+
1424
+ let classDeclarations = 0;
1425
+ const stack = [scope];
1426
+ while (stack.length > 0) {
1427
+ const node = stack.pop();
1428
+ if (node !== scope && (node.type === 'function_definition' ||
1429
+ node.type === 'async_function_definition' ||
1430
+ node.type === 'class_definition')) {
1431
+ const declaredName = node.childForFieldName('name')?.text;
1432
+ if (declaredName === name) {
1433
+ if (node.type === 'class_definition') classDeclarations++;
1434
+ else return true;
1435
+ }
1436
+ // The declaration name binds in this scope; its body does not.
1437
+ continue;
1438
+ }
1439
+ if (node.type === 'decorated_definition') {
1440
+ let declaration = null;
1441
+ for (let i = 0; i < node.namedChildCount; i++) {
1442
+ const child = node.namedChild(i);
1443
+ if (child.type === 'function_definition' ||
1444
+ child.type === 'async_function_definition' ||
1445
+ child.type === 'class_definition') {
1446
+ declaration = child;
1447
+ break;
1448
+ }
1449
+ }
1450
+ if (declaration) {
1451
+ const declaredName = declaration.childForFieldName('name')?.text;
1452
+ if (declaredName === name) {
1453
+ if (declaration.type === 'class_definition') classDeclarations++;
1454
+ else return true;
1455
+ }
1456
+ continue;
1457
+ }
1458
+ }
1459
+ if (node.type === 'assignment' ||
1460
+ node.type === 'augmented_assignment' ||
1461
+ node.type === 'named_expression') {
1462
+ if (pythonTargetBindsName(
1463
+ node.childForFieldName('left') || node.childForFieldName('name'),
1464
+ name)) return true;
1465
+ } else if (node.type === 'for_statement' || node.type === 'for_in_clause') {
1466
+ if (pythonTargetBindsName(node.childForFieldName('left'), name)) return true;
1467
+ } else if (node.type === 'with_item') {
1468
+ const value = node.childForFieldName('value') || node.namedChild(0);
1469
+ const target = value?.type === 'as_pattern'
1470
+ ? value.namedChild(value.namedChildCount - 1) : null;
1471
+ if (pythonTargetBindsName(
1472
+ target?.type === 'as_pattern_target' ? target.namedChild(0) : target,
1473
+ name)) return true;
1474
+ }
1475
+ for (let i = node.namedChildCount - 1; i >= 0; i--) {
1476
+ stack.push(node.namedChild(i));
1477
+ }
1478
+ }
1479
+ // Two conditional/repeated local class declarations with the same name
1480
+ // are not one stable identity. Refuse the heuristic and leave the value
1481
+ // on the visible unverified rail.
1482
+ return classDeclarations > 1;
1483
+ }
1484
+
1485
+ function pythonModuleAliases(tree) {
1486
+ const aliases = new Set();
1487
+ traverseTreeCached(tree.rootNode, node => {
1488
+ if (node.type !== 'import_statement') return true;
1489
+ for (let i = 0; i < node.namedChildCount; i++) {
1490
+ const child = node.namedChild(i);
1491
+ if (child.type === 'dotted_name') {
1492
+ const first = child.namedChild(0);
1493
+ if (first?.type === 'identifier') aliases.add(first.text);
1494
+ } else if (child.type === 'aliased_import') {
1495
+ const alias = child.childForFieldName('alias');
1496
+ if (alias?.type === 'identifier') aliases.add(alias.text);
1497
+ }
1498
+ }
1499
+ return true;
1500
+ });
1501
+ return aliases;
1502
+ }
1503
+
1234
1504
  function findCallsInCode(code, parser) {
1235
1505
  const tree = parseTree(parser, code);
1236
1506
  const calls = [];
@@ -1373,89 +1643,22 @@ function findCallsInCode(code, parser) {
1373
1643
  };
1374
1644
  };
1375
1645
 
1376
- // fix #203: is a bare-identifier function REFERENCE shadowed by a local of
1377
- // the enclosing function? Python locals are FUNCTION-scoped and an
1378
- // assignment ANYWHERE in the function makes the name local for ALL its
1379
- // references (UnboundLocalError semantics) — so scan the whole enclosing
1380
- // function subtree (excluding nested function bodies, which are separate
1381
- // scopes) for assignment/for/with-as/walrus bindings of the name.
1382
- // Enclosing-function PARAMS are checked at query time in findCallers.
1383
- const _targetBindsName = (left, name) => {
1384
- if (!left) return false;
1385
- if (left.type === 'identifier' && left.text === name) return true;
1386
- if (left.type === 'pattern_list' || left.type === 'tuple_pattern') {
1387
- for (let j = 0; j < left.namedChildCount; j++) {
1388
- if (left.namedChild(j).type === 'identifier' && left.namedChild(j).text === name) return true;
1389
- }
1390
- }
1391
- return false;
1392
- };
1393
- const _bindsNameInScope = (scopeNode, name) => {
1394
- for (let i = 0; i < scopeNode.namedChildCount; i++) {
1395
- const c = scopeNode.namedChild(i);
1396
- if (c.type === 'function_definition' || c.type === 'async_function_definition' ||
1397
- c.type === 'class_definition') {
1398
- // The body is a separate scope, but the DEF NAME itself is an
1399
- // assignment in THIS scope (fix #218: a nested `def get_style`
1400
- // shadows the name for sibling references).
1401
- if (c.childForFieldName('name')?.text === name) return true;
1402
- continue;
1403
- }
1404
- if (c.type === 'lambda') continue; // separate scope, no name
1405
- if (c.type === 'assignment' || c.type === 'augmented_assignment' || c.type === 'named_expression') {
1406
- if (_targetBindsName(c.childForFieldName('left') || c.childForFieldName('name'), name)) return true;
1407
- } else if (c.type === 'for_statement') {
1408
- if (_targetBindsName(c.childForFieldName('left'), name)) return true;
1409
- } else if (c.type === 'with_statement') {
1410
- // with open(f) as fh: — as-target is inside with_clause/with_item
1411
- const text = c.namedChild(0)?.text || '';
1412
- const m = text.match(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/);
1413
- if (m && m[1] === name) return true;
1414
- }
1415
- if (_bindsNameInScope(c, name)) return true;
1416
- }
1417
- return false;
1418
- };
1419
- const PY_COMPREHENSIONS = new Set([
1420
- 'generator_expression', 'list_comprehension', 'set_comprehension', 'dictionary_comprehension',
1421
- ]);
1422
- const isShadowedByLocal = (refNode, name) => {
1423
- for (let p = refNode.parent; p; p = p.parent) {
1424
- // Comprehension for-clause targets are scoped to the comprehension
1425
- // itself (PEP 3110-era scoping): `cell_len(line) for line in lines`
1426
- // binds `line` ONLY inside the comprehension — block-accurate, so
1427
- // check on the way up rather than function-wide (fix #218).
1428
- if (PY_COMPREHENSIONS.has(p.type)) {
1429
- for (let i = 0; i < p.namedChildCount; i++) {
1430
- const c = p.namedChild(i);
1431
- if (c.type === 'for_in_clause' && _targetBindsName(c.childForFieldName('left'), name)) return true;
1432
- }
1433
- }
1434
- // Lambda params shadow their body the same way (fix #218).
1435
- if (p.type === 'lambda') {
1436
- const params = p.childForFieldName('parameters');
1437
- if (params) for (let i = 0; i < params.namedChildCount; i++) {
1438
- const c = params.namedChild(i);
1439
- if (c.type === 'identifier' && c.text === name) return true;
1440
- if (c.type === 'default_parameter' && c.childForFieldName('name')?.text === name) return true;
1441
- }
1442
- }
1443
- if (p.type === 'function_definition' || p.type === 'async_function_definition') {
1444
- const params = p.childForFieldName('parameters');
1445
- if (params) {
1446
- for (let i = 0; i < params.namedChildCount; i++) {
1447
- const prm = params.namedChild(i);
1448
- const prmName = prm.type === 'identifier'
1449
- ? prm
1450
- : (prm.childForFieldName('name') || prm.namedChild(0));
1451
- if (prmName?.type === 'identifier' && prmName.text === name) return true;
1452
- }
1453
- }
1454
- const body = p.childForFieldName('body');
1455
- return body ? _bindsNameInScope(body, name) : false;
1456
- }
1457
- }
1458
- return false; // module level — that's a module binding, not a shadow
1646
+ const isShadowedByLocal = isPythonNameShadowedAt;
1647
+ const exactConstructorInfo = funcNode => {
1648
+ const ctor = constructorTypeInfo(funcNode);
1649
+ if (!ctor) return undefined;
1650
+ if (!ctor.qualifier) {
1651
+ return isPythonConstructorValueShadowedAt(funcNode, ctor.type)
1652
+ ? undefined : ctor;
1653
+ }
1654
+ // Attribute calls are constructors only when the receiver is an
1655
+ // unshadowed module alias. `self.Widget()` / `factory.Widget()` and a
1656
+ // parameter shadowing `import pkg` are dynamic attribute dispatch,
1657
+ // not type identity. Previously their qualifier was discarded and
1658
+ // the terminal name could borrow an unrelated project class.
1659
+ const root = ctor.qualifier.split('.')[0];
1660
+ return moduleAliases.has(root) && !isShadowedByLocal(funcNode, root)
1661
+ ? ctor : undefined;
1459
1662
  };
1460
1663
 
1461
1664
  traverseTree(tree.rootNode, (node) => {
@@ -1595,12 +1798,14 @@ function findCallsInCode(code, parser) {
1595
1798
  };
1596
1799
  recordWithTargets(targetId || target);
1597
1800
  if (ctx?.type === 'call' && targetId?.type === 'identifier') {
1598
- const ctor = constructorTypeInfo(ctx.childForFieldName('function'));
1599
- if (ctor) {
1600
- localVarTypes.set(targetId.text, ctor.type);
1801
+ const constructorNode = ctx.childForFieldName('function');
1802
+ const exactCtor = exactConstructorInfo(constructorNode);
1803
+ if (exactCtor) {
1804
+ localVarTypes.set(targetId.text, exactCtor.type);
1601
1805
  constructedReceiverVars.add(targetId.text);
1602
- if (ctor.qualifier && moduleAliases.has(ctor.qualifier.split('.')[0])) {
1603
- localVarTypeQualifiers.set(targetId.text, ctor.qualifier);
1806
+ if (exactCtor.qualifier &&
1807
+ moduleAliases.has(exactCtor.qualifier.split('.')[0])) {
1808
+ localVarTypeQualifiers.set(targetId.text, exactCtor.qualifier);
1604
1809
  } else {
1605
1810
  localVarTypeQualifiers.delete(targetId.text);
1606
1811
  }
@@ -1800,12 +2005,14 @@ function findCallsInCode(code, parser) {
1800
2005
  nonCallableNames.add(left.text);
1801
2006
  // Infer type from constructor call: x = ClassName(...) or
1802
2007
  // x = pkg.ClassName(...). Python convention: classes start uppercase
1803
- const ctor = constructorTypeInfo(right.childForFieldName('function'));
1804
- if (ctor) {
1805
- localVarTypes.set(left.text, ctor.type);
2008
+ const constructorNode = right.childForFieldName('function');
2009
+ const exactCtor = exactConstructorInfo(constructorNode);
2010
+ if (exactCtor) {
2011
+ localVarTypes.set(left.text, exactCtor.type);
1806
2012
  constructedReceiverVars.add(left.text);
1807
- if (ctor.qualifier && moduleAliases.has(ctor.qualifier.split('.')[0])) {
1808
- localVarTypeQualifiers.set(left.text, ctor.qualifier);
2013
+ if (exactCtor.qualifier &&
2014
+ moduleAliases.has(exactCtor.qualifier.split('.')[0])) {
2015
+ localVarTypeQualifiers.set(left.text, exactCtor.qualifier);
1809
2016
  } else {
1810
2017
  localVarTypeQualifiers.delete(left.text);
1811
2018
  }
@@ -1830,7 +2037,26 @@ function findCallsInCode(code, parser) {
1830
2037
  const enclosingFunction = getCurrentEnclosingFunction();
1831
2038
  let uncertain = false;
1832
2039
  const assignedContext = contextTargetOf(node);
1833
- const assignedTo = assignmentTargetOf(node) || assignedContext;
2040
+ let assignedTo = assignmentTargetOf(node) || assignedContext;
2041
+ // For-loop iterable producer (fix #294): the loop variable's
2042
+ // provenance comes from this call. assignedIter marks that the
2043
+ // variable holds an ELEMENT, not the return value — the flow map
2044
+ // uses it for external-producer demotion only, never typing.
2045
+ let assignedIter = false;
2046
+ let assignedIterRest = null;
2047
+ if (!assignedTo) {
2048
+ const iterTarget = iterTargetOf(node);
2049
+ if (iterTarget) {
2050
+ assignedTo = iterTarget.first;
2051
+ assignedIterRest = iterTarget.rest;
2052
+ assignedIter = true;
2053
+ }
2054
+ }
2055
+ const assignedIterFields = {
2056
+ ...(assignedIter && { assignedIter: true }),
2057
+ ...(assignedIter && assignedIterRest && assignedIterRest.length > 0 &&
2058
+ { assignedTupleRest: assignedIterRest }),
2059
+ };
1834
2060
 
1835
2061
  // Call-site arg count (positional + keyword) for arity pruning.
1836
2062
  // *args/**kwargs splats make the count open-ended — flag them so
@@ -1870,6 +2096,7 @@ function findCallsInCode(code, parser) {
1870
2096
  ...(recvType && { receiverType: recvType }),
1871
2097
  ...(recvIsModule && { receiverIsModule: true }),
1872
2098
  ...(assignedTo && { assignedTo }),
2099
+ ...assignedIterFields,
1873
2100
  ...(assignedContext && { assignedContext: true }),
1874
2101
  argCount,
1875
2102
  ...(argSpread && { argSpread: true }),
@@ -1886,6 +2113,7 @@ function findCallsInCode(code, parser) {
1886
2113
  line: node.startPosition.row + 1,
1887
2114
  isMethod: false,
1888
2115
  ...(assignedTo && { assignedTo }),
2116
+ ...assignedIterFields,
1889
2117
  ...(assignedContext && { assignedContext: true }),
1890
2118
  argCount,
1891
2119
  ...(argSpread && { argSpread: true }),
@@ -2067,6 +2295,7 @@ function findCallsInCode(code, parser) {
2067
2295
  receiverCapabilityGuard: capabilityGuard,
2068
2296
  }),
2069
2297
  ...(assignedTo && { assignedTo }),
2298
+ ...assignedIterFields,
2070
2299
  ...(assignedContext && { assignedContext: true }),
2071
2300
  argCount,
2072
2301
  ...(argSpread && { argSpread: true }),
@@ -2101,6 +2330,36 @@ function findCallsInCode(code, parser) {
2101
2330
  enclosingFunction
2102
2331
  });
2103
2332
  }
2333
+ // Method-value references (fix #295, flask-check-measured):
2334
+ // `pytest.raises(NIE, t.check, None)` passes the bound
2335
+ // method t.check — an attribute argument on a one-hop
2336
+ // identifier receiver is a potential method value, typed
2337
+ // from the same localVarTypes evidence method calls use.
2338
+ // No isPotentialCallback: the record rides the MAIN path's
2339
+ // full receiver physics (the JS HOF member-value
2340
+ // convention), not the bare-name callback fast path.
2341
+ // self/cls receivers (same-class values) and module-alias
2342
+ // receivers (name-level ownership physics) are
2343
+ // classified-deferred families — not emitted.
2344
+ if (arg.type === 'attribute') {
2345
+ const mvObj = arg.childForFieldName('object');
2346
+ const mvAttr = arg.childForFieldName('attribute');
2347
+ if (mvObj?.type === 'identifier' && mvAttr &&
2348
+ !PYTHON_SKIP.has(mvObj.text) &&
2349
+ !moduleAliases.has(mvObj.text) &&
2350
+ !PYTHON_SKIP.has(mvAttr.text)) {
2351
+ const mvType = localVarTypes.get(mvObj.text);
2352
+ calls.push({
2353
+ name: mvAttr.text,
2354
+ line: mvAttr.startPosition.row + 1,
2355
+ isMethod: true,
2356
+ receiver: mvObj.text,
2357
+ ...(mvType && { receiverType: mvType }),
2358
+ isFunctionReference: true,
2359
+ enclosingFunction
2360
+ });
2361
+ }
2362
+ }
2104
2363
  // Scan dict literal args for function refs in values
2105
2364
  // e.g., do_request({'on_success': handle_success})
2106
2365
  if (arg.type === 'dictionary') {
@@ -2209,10 +2468,24 @@ function findImportsInCode(code, parser) {
2209
2468
  const imports = [];
2210
2469
  let importAliases = null; // {original, local}[] — tracks renamed imports
2211
2470
 
2471
+ // Imports nested in a function/lambda do not execute during ordinary
2472
+ // module initialization. Preserve that AST fact so dependency-cycle
2473
+ // reporting can distinguish an eager import loop from a deliberate lazy
2474
+ // edge without deleting either edge from the graph.
2475
+ const isDeferredImport = (node) => {
2476
+ for (let parent = node.parent; parent; parent = parent.parent) {
2477
+ if (parent.type === 'function_definition' || parent.type === 'lambda') {
2478
+ return true;
2479
+ }
2480
+ }
2481
+ return false;
2482
+ };
2483
+
2212
2484
  traverseTreeCached(tree.rootNode, (node) => {
2213
2485
  // import statement: import os, import sys as system
2214
2486
  if (node.type === 'import_statement') {
2215
2487
  const line = node.startPosition.row + 1;
2488
+ const deferred = isDeferredImport(node);
2216
2489
 
2217
2490
  for (let i = 0; i < node.namedChildCount; i++) {
2218
2491
  const child = node.namedChild(i);
@@ -2228,20 +2501,23 @@ function findImportsInCode(code, parser) {
2228
2501
  module: parts[0],
2229
2502
  names: [parts[0]],
2230
2503
  type: 'import',
2231
- line
2504
+ line,
2505
+ ...(deferred && { deferred: true })
2232
2506
  });
2233
2507
  imports.push({
2234
2508
  module: child.text,
2235
2509
  names: [],
2236
2510
  type: 'import-submodule',
2237
- line
2511
+ line,
2512
+ ...(deferred && { deferred: true })
2238
2513
  });
2239
2514
  } else {
2240
2515
  imports.push({
2241
2516
  module: child.text,
2242
2517
  names: [child.text],
2243
2518
  type: 'import',
2244
- line
2519
+ line,
2520
+ ...(deferred && { deferred: true })
2245
2521
  });
2246
2522
  }
2247
2523
  } else if (child.type === 'aliased_import') {
@@ -2253,7 +2529,8 @@ function findImportsInCode(code, parser) {
2253
2529
  module: nameNode.text,
2254
2530
  names: [aliasNode ? aliasNode.text : nameNode.text.split('.').pop()],
2255
2531
  type: 'import',
2256
- line
2532
+ line,
2533
+ ...(deferred && { deferred: true })
2257
2534
  });
2258
2535
  if (aliasNode && aliasNode.text !== nameNode.text) {
2259
2536
  if (!importAliases) importAliases = [];
@@ -2268,6 +2545,7 @@ function findImportsInCode(code, parser) {
2268
2545
  // from ... import statement
2269
2546
  if (node.type === 'import_from_statement') {
2270
2547
  const line = node.startPosition.row + 1;
2548
+ const deferred = isDeferredImport(node);
2271
2549
  let modulePath = '';
2272
2550
  const names = [];
2273
2551
 
@@ -2300,7 +2578,8 @@ function findImportsInCode(code, parser) {
2300
2578
  module: modulePath,
2301
2579
  names,
2302
2580
  type: isRelative ? 'relative' : 'from',
2303
- line
2581
+ line,
2582
+ ...(deferred && { deferred: true })
2304
2583
  });
2305
2584
  }
2306
2585
  return true;
@@ -2315,13 +2594,15 @@ function findImportsInCode(code, parser) {
2315
2594
  const firstArg = argsNode.namedChild(0);
2316
2595
  if ((funcName === 'importlib.import_module' || funcName === '__import__') && firstArg) {
2317
2596
  const line = node.startPosition.row + 1;
2597
+ const deferred = isDeferredImport(node);
2318
2598
  const isLiteral = firstArg.type === 'string';
2319
2599
  imports.push({
2320
2600
  module: isLiteral ? firstArg.text.replace(/^['"]|['"]$/g, '') : firstArg.text,
2321
2601
  names: [],
2322
2602
  type: 'dynamic',
2323
2603
  line,
2324
- dynamic: !isLiteral
2604
+ dynamic: !isLiteral,
2605
+ ...(deferred && { deferred: true })
2325
2606
  });
2326
2607
  }
2327
2608
  }
@@ -2423,6 +2704,7 @@ function findExportsInCode(code, parser) {
2423
2704
  function findUsagesInCode(code, name, parser, tree) {
2424
2705
  tree = tree || parseTree(parser, code);
2425
2706
  const usages = [];
2707
+ const moduleAliases = pythonModuleAliases(tree);
2426
2708
 
2427
2709
  visitNameNodes(tree, code, name, (node) => {
2428
2710
  // Only look for identifiers with the matching name
@@ -2494,7 +2776,16 @@ function findUsagesInCode(code, name, parser, tree) {
2494
2776
  // Track receiver for member expressions (obj.name → receiver = 'obj')
2495
2777
  const object = parent.childForFieldName('object');
2496
2778
  if (object && object.type === 'identifier') {
2497
- usages.push({ line, column, usageType, receiver: object.text });
2779
+ usages.push({
2780
+ line,
2781
+ column,
2782
+ usageType,
2783
+ receiver: object.text,
2784
+ ...(moduleAliases.has(object.text) && { receiverIsModule: true }),
2785
+ ...(isPythonNameShadowedAt(object, object.text) && {
2786
+ receiverLocalBinding: true,
2787
+ }),
2788
+ });
2498
2789
  return true;
2499
2790
  }
2500
2791
  // Constructed receiver: ColorTriplet(...).normalized is a