ucn 3.8.26 → 4.0.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.
@@ -11,7 +11,8 @@ const {
11
11
  nodeToLocation,
12
12
  parseStructuredParams,
13
13
  extractPythonDocstring,
14
- paramTypesFromStructured
14
+ paramTypesFromStructured,
15
+ visitNameNodes,
15
16
  } = require('./utils');
16
17
  const { PARSE_OPTIONS, safeParse } = require('./index');
17
18
 
@@ -228,6 +229,40 @@ function _processState(node, objects, lines) {
228
229
  return false;
229
230
  }
230
231
 
232
+ /**
233
+ * Collect module-scope assignment target names (fix #217). A module-level
234
+ * `render = something` (including inside if/try/for blocks — module control
235
+ * flow still binds module attributes) or a `global name` declaration creates
236
+ * a module attribute the import-binding name-chase cannot model, so the
237
+ * chase must treat such names as undetermined rather than provably absent.
238
+ */
239
+ function _processModuleAssign(node, names) {
240
+ if (node.type === 'global_statement') {
241
+ // `global X` declares that enclosing-function assignments of X bind
242
+ // the MODULE attribute — collect regardless of nesting.
243
+ for (let i = 0; i < node.namedChildCount; i++) {
244
+ const c = node.namedChild(i);
245
+ if (c.type === 'identifier') names.add(c.text);
246
+ }
247
+ return;
248
+ }
249
+ if (node.type !== 'assignment' && node.type !== 'named_expression') return;
250
+ for (let p = node.parent; p; p = p.parent) {
251
+ // Function scope → local; class body → class attr. Either way, not a
252
+ // module attribute. if/try/for/with blocks at module level still are.
253
+ if (p.type === 'function_definition' || p.type === 'class_definition') return;
254
+ }
255
+ const left = node.childForFieldName('left') || node.childForFieldName('name');
256
+ if (!left) return;
257
+ if (left.type === 'identifier') names.add(left.text);
258
+ else if (left.type === 'tuple' || left.type === 'pattern_list') {
259
+ for (let i = 0; i < left.namedChildCount; i++) {
260
+ const c = left.namedChild(i);
261
+ if (c.type === 'identifier') names.add(c.text);
262
+ }
263
+ }
264
+ }
265
+
231
266
  // --- End single-pass helpers ---
232
267
 
233
268
  /**
@@ -419,6 +454,7 @@ function parse(code, parser) {
419
454
  const functions = [];
420
455
  const classes = [];
421
456
  const stateObjects = [];
457
+ const moduleAssigned = new Set();
422
458
  const processedFn = new Set();
423
459
  const processedCls = new Set();
424
460
 
@@ -426,6 +462,7 @@ function parse(code, parser) {
426
462
  _processFunction(node, functions, processedFn, lines, code);
427
463
  _processClass(node, classes, processedCls, lines);
428
464
  _processState(node, stateObjects, lines);
465
+ _processModuleAssign(node, moduleAssigned);
429
466
  return true;
430
467
  });
431
468
 
@@ -439,6 +476,7 @@ function parse(code, parser) {
439
476
  functions,
440
477
  classes,
441
478
  stateObjects,
479
+ ...(moduleAssigned.size > 0 && { moduleAssignedNames: [...moduleAssigned].sort() }),
442
480
  imports: [],
443
481
  exports: []
444
482
  };
@@ -450,6 +488,120 @@ function parse(code, parser) {
450
488
  * @param {object} parser - Tree-sitter parser instance
451
489
  * @returns {Array<{name: string, line: number, isMethod: boolean, receiver?: string}>}
452
490
  */
491
+ // Builtin types for literal method receivers: {'a': 1}.get('a') is dict.get,
492
+ // never a project class method. Keys are tree-sitter node types.
493
+ const PY_LITERAL_RECEIVER_TYPES = {
494
+ dictionary: 'dict',
495
+ dictionary_comprehension: 'dict',
496
+ list: 'list',
497
+ list_comprehension: 'list',
498
+ set: 'set',
499
+ set_comprehension: 'set',
500
+ string: 'str',
501
+ concatenated_string: 'str',
502
+ tuple: 'tuple',
503
+ };
504
+
505
+ // typing wrappers whose first argument is the actual value type
506
+ const PY_TYPE_WRAPPERS = new Set(['Optional', 'Annotated', 'Final', 'ClassVar']);
507
+
508
+ /**
509
+ * Extract a single concrete type name from an annotation's `type` node.
510
+ * Conservative by design: a wrong type would exclude true callers downstream
511
+ * (receiver-type-mismatch), so anything ambiguous returns undefined.
512
+ * Handles: Foo · pkg.Foo · Foo | None · Optional[Foo] · "Foo" · dict[str, int]
513
+ */
514
+ function typeNameFromAnnotation(typeNode) {
515
+ if (!typeNode) return undefined;
516
+ const inner = typeNode.namedChildCount > 0 ? typeNode.namedChild(0) : null;
517
+ return typeNameFromExpr(inner);
518
+ }
519
+
520
+ function typeNameFromExpr(node) {
521
+ if (!node) return undefined;
522
+ switch (node.type) {
523
+ case 'identifier':
524
+ return node.text;
525
+ case 'attribute': {
526
+ // dotted name: classes match by name in the symbol table → last segment
527
+ const attr = node.childForFieldName('attribute');
528
+ return attr?.text;
529
+ }
530
+ case 'binary_operator': {
531
+ // PEP 604 union: X | None → X; unions of two real types are ambiguous
532
+ const left = node.namedChild(0);
533
+ const right = node.namedChild(1);
534
+ if (left?.type === 'none' && right?.type !== 'none') return typeNameFromExpr(right);
535
+ if (right?.type === 'none' && left?.type !== 'none') return typeNameFromExpr(left);
536
+ return undefined;
537
+ }
538
+ case 'subscript': {
539
+ // typing.Optional[Foo] parses as subscript when base is dotted
540
+ const base = typeNameFromExpr(node.childForFieldName('value'));
541
+ if (PY_TYPE_WRAPPERS.has(base)) {
542
+ return typeNameFromExpr(node.childForFieldName('subscript'));
543
+ }
544
+ return base; // dict[str, int] → the receiver IS a dict
545
+ }
546
+ case 'generic_type': {
547
+ // Optional[Foo] / Mapping[str, int] in annotation position
548
+ const base = typeNameFromExpr(node.namedChild(0));
549
+ if (PY_TYPE_WRAPPERS.has(base)) {
550
+ const params = node.namedChild(1); // type_parameter → type wrappers
551
+ const firstType = params && params.namedChildCount > 0 ? params.namedChild(0) : null;
552
+ return typeNameFromAnnotation(firstType);
553
+ }
554
+ return base;
555
+ }
556
+ case 'string': {
557
+ // forward reference: "Foo" — only accept a bare dotted name
558
+ for (let i = 0; i < node.childCount; i++) {
559
+ const c = node.child(i);
560
+ if (c.type === 'string_content') {
561
+ const txt = c.text.trim();
562
+ if (/^[A-Za-z_][\w.]*$/.test(txt)) return txt.split('.').pop();
563
+ }
564
+ }
565
+ return undefined;
566
+ }
567
+ default:
568
+ return undefined;
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Variable receiving this call's result: `x = foo(...)` / `x = await foo(...)`
574
+ * → 'x'. Identifier targets only (no tuples/attributes). Compared by node id —
575
+ * tree-sitter wrapper objects are not identity-stable.
576
+ */
577
+ function assignmentTargetOf(callNode) {
578
+ let n = callNode;
579
+ let p = n.parent;
580
+ if (p && p.type === 'await') { n = p; p = n.parent; }
581
+ if (p && p.type === 'assignment') {
582
+ const right = p.childForFieldName('right');
583
+ const left = p.childForFieldName('left');
584
+ if (right && right.id === n.id && left?.type === 'identifier') return left.text;
585
+ }
586
+ return undefined;
587
+ }
588
+
589
+ /**
590
+ * Type name from a constructor-call callee: ClassName(...) or pkg.ClassName(...).
591
+ * Uppercase-first heuristic (Python class naming convention).
592
+ */
593
+ function constructorTypeName(funcNode) {
594
+ if (!funcNode) return undefined;
595
+ if (funcNode.type === 'identifier') {
596
+ return /^[A-Z]/.test(funcNode.text) ? funcNode.text : undefined;
597
+ }
598
+ if (funcNode.type === 'attribute') {
599
+ const attr = funcNode.childForFieldName('attribute');
600
+ return attr && /^[A-Z]/.test(attr.text) ? attr.text : undefined;
601
+ }
602
+ return undefined;
603
+ }
604
+
453
605
  function findCallsInCode(code, parser) {
454
606
  const tree = parseTree(parser, code);
455
607
  const calls = [];
@@ -457,6 +609,16 @@ function findCallsInCode(code, parser) {
457
609
  const aliases = new Map(); // Track local aliases: aliasName -> originalName
458
610
  const nonCallableNames = new Set(); // Track names assigned non-callable values
459
611
  const localVarTypes = new Map(); // Track local variable types: varName -> typeName (for receiverType inference)
612
+ // Member-access aliases (fix #218): `append = output.append` makes a later
613
+ // bare `append(part)` a METHOD call on `output` — it must carry the
614
+ // receiver's evidence, never bind by bare name to a same-file def
615
+ // (rich text.py: 7 list.append calls confirmed exact-binding against
616
+ // Text.append). aliasName -> { receiver: string|null, attr: string };
617
+ // receiver is null for chained/deep objects (self._text.append) — the
618
+ // rewritten call is then receiver-blind and routes through dispatch tiering.
619
+ const memberAliases = new Map();
620
+ const memberAliasesStack = []; // function-scoped save/restore, like localVarTypes
621
+ const moduleAliases = new Set(); // Names bound to MODULES (import httpx / import numpy as np)
460
622
  const localVarTypesStack = []; // Stack for function-scoped save/restore of localVarTypes
461
623
 
462
624
  // Helper: extract first string-arg literal from a call node.
@@ -541,7 +703,99 @@ function findCallsInCode(code, parser) {
541
703
  : null;
542
704
  };
543
705
 
706
+ // fix #203: is a bare-identifier function REFERENCE shadowed by a local of
707
+ // the enclosing function? Python locals are FUNCTION-scoped and an
708
+ // assignment ANYWHERE in the function makes the name local for ALL its
709
+ // references (UnboundLocalError semantics) — so scan the whole enclosing
710
+ // function subtree (excluding nested function bodies, which are separate
711
+ // scopes) for assignment/for/with-as/walrus bindings of the name.
712
+ // Enclosing-function PARAMS are checked at query time in findCallers.
713
+ const _targetBindsName = (left, name) => {
714
+ if (!left) return false;
715
+ if (left.type === 'identifier' && left.text === name) return true;
716
+ if (left.type === 'pattern_list' || left.type === 'tuple_pattern') {
717
+ for (let j = 0; j < left.namedChildCount; j++) {
718
+ if (left.namedChild(j).type === 'identifier' && left.namedChild(j).text === name) return true;
719
+ }
720
+ }
721
+ return false;
722
+ };
723
+ const _bindsNameInScope = (scopeNode, name) => {
724
+ for (let i = 0; i < scopeNode.namedChildCount; i++) {
725
+ const c = scopeNode.namedChild(i);
726
+ if (c.type === 'function_definition' || c.type === 'async_function_definition' ||
727
+ c.type === 'class_definition') {
728
+ // The body is a separate scope, but the DEF NAME itself is an
729
+ // assignment in THIS scope (fix #218: a nested `def get_style`
730
+ // shadows the name for sibling references).
731
+ if (c.childForFieldName('name')?.text === name) return true;
732
+ continue;
733
+ }
734
+ if (c.type === 'lambda') continue; // separate scope, no name
735
+ if (c.type === 'assignment' || c.type === 'augmented_assignment' || c.type === 'named_expression') {
736
+ if (_targetBindsName(c.childForFieldName('left') || c.childForFieldName('name'), name)) return true;
737
+ } else if (c.type === 'for_statement') {
738
+ if (_targetBindsName(c.childForFieldName('left'), name)) return true;
739
+ } else if (c.type === 'with_statement') {
740
+ // with open(f) as fh: — as-target is inside with_clause/with_item
741
+ const text = c.namedChild(0)?.text || '';
742
+ const m = text.match(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/);
743
+ if (m && m[1] === name) return true;
744
+ }
745
+ if (_bindsNameInScope(c, name)) return true;
746
+ }
747
+ return false;
748
+ };
749
+ const PY_COMPREHENSIONS = new Set([
750
+ 'generator_expression', 'list_comprehension', 'set_comprehension', 'dictionary_comprehension',
751
+ ]);
752
+ const isShadowedByLocal = (refNode, name) => {
753
+ for (let p = refNode.parent; p; p = p.parent) {
754
+ // Comprehension for-clause targets are scoped to the comprehension
755
+ // itself (PEP 3110-era scoping): `cell_len(line) for line in lines`
756
+ // binds `line` ONLY inside the comprehension — block-accurate, so
757
+ // check on the way up rather than function-wide (fix #218).
758
+ if (PY_COMPREHENSIONS.has(p.type)) {
759
+ for (let i = 0; i < p.namedChildCount; i++) {
760
+ const c = p.namedChild(i);
761
+ if (c.type === 'for_in_clause' && _targetBindsName(c.childForFieldName('left'), name)) return true;
762
+ }
763
+ }
764
+ // Lambda params shadow their body the same way (fix #218).
765
+ if (p.type === 'lambda') {
766
+ const params = p.childForFieldName('parameters');
767
+ if (params) for (let i = 0; i < params.namedChildCount; i++) {
768
+ const c = params.namedChild(i);
769
+ if (c.type === 'identifier' && c.text === name) return true;
770
+ if (c.type === 'default_parameter' && c.childForFieldName('name')?.text === name) return true;
771
+ }
772
+ }
773
+ if (p.type === 'function_definition' || p.type === 'async_function_definition') {
774
+ const body = p.childForFieldName('body');
775
+ return body ? _bindsNameInScope(body, name) : false;
776
+ }
777
+ }
778
+ return false; // module level — that's a module binding, not a shadow
779
+ };
780
+
544
781
  traverseTree(tree.rootNode, (node) => {
782
+ // Track module-alias bindings: `import httpx` binds 'httpx' (a module),
783
+ // `import numpy as np` binds 'np'. Method calls through these receivers
784
+ // dispatch to module functions, never to class methods. `from x import y`
785
+ // is skipped — y may be a symbol, not a module.
786
+ if (node.type === 'import_statement') {
787
+ for (let i = 0; i < node.namedChildCount; i++) {
788
+ const child = node.namedChild(i);
789
+ if (child.type === 'dotted_name') {
790
+ const first = child.namedChild(0);
791
+ if (first?.type === 'identifier') moduleAliases.add(first.text);
792
+ } else if (child.type === 'aliased_import') {
793
+ const alias = child.childForFieldName('alias');
794
+ if (alias?.type === 'identifier') moduleAliases.add(alias.text);
795
+ }
796
+ }
797
+ }
798
+
545
799
  // Track function entry
546
800
  if (isFunctionNode(node)) {
547
801
  // Use decorated_definition start line if present, to match symbol index
@@ -556,6 +810,7 @@ function findCallsInCode(code, parser) {
556
810
  });
557
811
  // Save localVarTypes so inner declarations don't leak to sibling functions
558
812
  localVarTypesStack.push(new Map(localVarTypes));
813
+ memberAliasesStack.push(new Map(memberAliases));
559
814
  }
560
815
 
561
816
  // Track parameter type annotations: def foo(x: Foo) → x is Foo
@@ -564,9 +819,24 @@ function findCallsInCode(code, parser) {
564
819
  const nameNode = node.childForFieldName('name') || node.namedChild(0);
565
820
  const typeNode = node.childForFieldName('type');
566
821
  if (nameNode?.type === 'identifier' && typeNode) {
567
- const typeId = typeNode.namedChildCount > 0 ? typeNode.namedChild(0) : null;
568
- if (typeId?.type === 'identifier' && !['self', 'cls'].includes(nameNode.text)) {
569
- localVarTypes.set(nameNode.text, typeId.text);
822
+ const typeName = typeNameFromAnnotation(typeNode);
823
+ if (typeName && !['self', 'cls'].includes(nameNode.text)) {
824
+ localVarTypes.set(nameNode.text, typeName);
825
+ }
826
+ }
827
+ }
828
+
829
+ // Track with-statement bindings: with Client() as c → c is Client
830
+ // (covers async with too — same with_item/as_pattern node shape)
831
+ if (node.type === 'with_item') {
832
+ const value = node.childForFieldName('value') || node.namedChild(0);
833
+ if (value?.type === 'as_pattern') {
834
+ const ctx = value.namedChild(0);
835
+ const target = value.namedChildCount > 1 ? value.namedChild(value.namedChildCount - 1) : null;
836
+ const targetId = target?.type === 'as_pattern_target' ? target.namedChild(0) : null;
837
+ if (ctx?.type === 'call' && targetId?.type === 'identifier') {
838
+ const ctorName = constructorTypeName(ctx.childForFieldName('function'));
839
+ if (ctorName) localVarTypes.set(targetId.text, ctorName);
570
840
  }
571
841
  }
572
842
  }
@@ -579,15 +849,39 @@ function findCallsInCode(code, parser) {
579
849
  // Track type annotation: x: Foo = ... → x is Foo
580
850
  const typeNode = node.childForFieldName('type');
581
851
  if (typeNode) {
582
- // type node wraps the actual type identifier
583
- const typeId = typeNode.namedChildCount > 0 ? typeNode.namedChild(0) : null;
584
- if (typeId?.type === 'identifier') {
585
- localVarTypes.set(left.text, typeId.text);
852
+ const typeName = typeNameFromAnnotation(typeNode);
853
+ if (typeName) {
854
+ localVarTypes.set(left.text, typeName);
586
855
  }
587
856
  }
857
+ memberAliases.delete(left.text); // any assignment rebinds the name
858
+ // Rebinding without a known type makes any previously inferred
859
+ // type stale — nearest-preceding-assignment semantics (#199's
860
+ // documented rule). Without this, `x = ""; x = render(); x.m()`
861
+ // would carry str and falsely exclude project methods.
862
+ if (!typeNode) localVarTypes.delete(left.text);
863
+ // Literal assignment types the variable (fix #218):
864
+ // ansi_bytes = b"…" → bytes; out = [] → list. Compiler-true,
865
+ // same trust grade as literal receivers ({}.get() → dict).
866
+ if (!typeNode && right && PY_LITERAL_RECEIVER_TYPES[right.type]) {
867
+ let litType = PY_LITERAL_RECEIVER_TYPES[right.type];
868
+ if (litType === 'str' && /^[rRuU]*[bB]/.test(right.text)) litType = 'bytes';
869
+ localVarTypes.set(left.text, litType);
870
+ }
588
871
  if (right?.type === 'identifier') {
589
872
  aliases.set(left.text, right.text);
590
873
  }
874
+ // Member-access alias (fix #218): append = output.append
875
+ else if (right?.type === 'attribute') {
876
+ const attrName = right.childForFieldName('attribute');
877
+ const objNode = right.childForFieldName('object');
878
+ if (attrName?.type === 'identifier') {
879
+ memberAliases.set(left.text, {
880
+ receiver: objNode?.type === 'identifier' ? objNode.text : null,
881
+ attr: attrName.text,
882
+ });
883
+ }
884
+ }
591
885
  // Track partial(fn, ...) aliases: fast_process = partial(process, mode='fast')
592
886
  else if (right?.type === 'call') {
593
887
  const callFunc = right.childForFieldName('function');
@@ -626,11 +920,11 @@ function findCallsInCode(code, parser) {
626
920
  // Exception: partial() already handled above via alias tracking.
627
921
  else if (right?.type === 'call' && !aliases.has(left.text)) {
628
922
  nonCallableNames.add(left.text);
629
- // Infer type from constructor call: x = ClassName(...)
630
- // Python convention: classes start with uppercase
631
- const callFunc = right.childForFieldName('function');
632
- if (callFunc?.type === 'identifier' && /^[A-Z]/.test(callFunc.text)) {
633
- localVarTypes.set(left.text, callFunc.text);
923
+ // Infer type from constructor call: x = ClassName(...) or
924
+ // x = pkg.ClassName(...). Python convention: classes start uppercase
925
+ const ctorName = constructorTypeName(right.childForFieldName('function'));
926
+ if (ctorName) {
927
+ localVarTypes.set(left.text, ctorName);
634
928
  }
635
929
  }
636
930
  // Third: subscript/attribute access results are non-callable data
@@ -651,20 +945,68 @@ function findCallsInCode(code, parser) {
651
945
 
652
946
  const enclosingFunction = getCurrentEnclosingFunction();
653
947
  let uncertain = false;
948
+ const assignedTo = assignmentTargetOf(node);
949
+
950
+ // Call-site arg count (positional + keyword) for arity pruning.
951
+ // *args/**kwargs splats make the count open-ended — flag them so
952
+ // pruning skips the site.
953
+ const callArgsNode = node.childForFieldName('arguments');
954
+ let argCount = 0;
955
+ let argSpread = false;
956
+ if (callArgsNode) {
957
+ for (let i = 0; i < callArgsNode.namedChildCount; i++) {
958
+ const arg = callArgsNode.namedChild(i);
959
+ if (arg.type === 'comment') continue;
960
+ if (arg.type === 'list_splat' || arg.type === 'dictionary_splat') argSpread = true;
961
+ argCount++;
962
+ }
963
+ }
654
964
 
655
965
  if (funcNode.type === 'identifier') {
656
- // Direct call: foo()
657
- const resolvedName = aliases.get(funcNode.text);
658
- const firstArg = getFirstStringArg(node);
659
- calls.push({
660
- name: funcNode.text,
661
- ...(resolvedName && { resolvedName }),
662
- line: node.startPosition.row + 1,
663
- isMethod: false,
664
- enclosingFunction,
665
- uncertain,
666
- ...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp })
667
- });
966
+ // Member-alias call (fix #218): `append = output.append` makes
967
+ // this bare call a METHOD call on the alias's receiver — emit
968
+ // it as one so receiver typing/dispatch tiering applies.
969
+ // Restricted to self-named aliases (alias === attr, the local
970
+ // bound-method optimization idiom): a renamed alias's line
971
+ // doesn't contain the method name, so it sits outside the
972
+ // account's text ground set — and never matched the target
973
+ // name before either (no FP to fix there).
974
+ const memberAlias = memberAliases.get(funcNode.text);
975
+ if (memberAlias && memberAlias.attr === funcNode.text) {
976
+ const recvType = memberAlias.receiver ? localVarTypes.get(memberAlias.receiver) : undefined;
977
+ const recvIsModule = !!memberAlias.receiver && moduleAliases.has(memberAlias.receiver) &&
978
+ !localVarTypes.has(memberAlias.receiver);
979
+ calls.push({
980
+ name: memberAlias.attr,
981
+ line: node.startPosition.row + 1,
982
+ isMethod: true,
983
+ aliasCall: true,
984
+ ...(memberAlias.receiver && { receiver: memberAlias.receiver }),
985
+ ...(recvType && { receiverType: recvType }),
986
+ ...(recvIsModule && { receiverIsModule: true }),
987
+ ...(assignedTo && { assignedTo }),
988
+ argCount,
989
+ ...(argSpread && { argSpread: true }),
990
+ enclosingFunction,
991
+ uncertain,
992
+ });
993
+ } else {
994
+ // Direct call: foo()
995
+ const resolvedName = aliases.get(funcNode.text);
996
+ const firstArg = getFirstStringArg(node);
997
+ calls.push({
998
+ name: funcNode.text,
999
+ ...(resolvedName && { resolvedName }),
1000
+ line: node.startPosition.row + 1,
1001
+ isMethod: false,
1002
+ ...(assignedTo && { assignedTo }),
1003
+ argCount,
1004
+ ...(argSpread && { argSpread: true }),
1005
+ enclosingFunction,
1006
+ uncertain,
1007
+ ...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp })
1008
+ });
1009
+ }
668
1010
  } else if (funcNode.type === 'attribute') {
669
1011
  // Method/attribute call: obj.foo() or self.attr.foo()
670
1012
  const attrNode = funcNode.childForFieldName('attribute');
@@ -673,6 +1015,13 @@ function findCallsInCode(code, parser) {
673
1015
  if (attrNode) {
674
1016
  let receiver = objNode?.type === 'identifier' ? objNode.text : undefined;
675
1017
  let selfAttribute = undefined;
1018
+ // Chained receiver (fix #219): the receiver IS a call —
1019
+ // fetch_data().json() — record the producer so findCallers
1020
+ // can type the receiver from its declared return
1021
+ // annotation. `(await f()).m()` unwraps to the call and
1022
+ // marks awaited (an un-awaited async producer's value is a
1023
+ // coroutine, not the annotation's type).
1024
+ let receiverCall, receiverCallIsMethod, receiverCallAwaited;
676
1025
 
677
1026
  // Detect super().method() pattern
678
1027
  if (objNode?.type === 'call') {
@@ -681,6 +1030,27 @@ function findCallsInCode(code, parser) {
681
1030
  receiver = 'super';
682
1031
  }
683
1032
  }
1033
+ {
1034
+ let recvNode = objNode;
1035
+ if (recvNode?.type === 'parenthesized_expression' &&
1036
+ recvNode.namedChild(0)?.type === 'await') {
1037
+ receiverCallAwaited = true;
1038
+ recvNode = recvNode.namedChild(0).namedChild(0);
1039
+ }
1040
+ if (recvNode?.type === 'call' && receiver !== 'super') {
1041
+ const prodFunc = recvNode.childForFieldName('function');
1042
+ if (prodFunc?.type === 'identifier') {
1043
+ receiverCall = prodFunc.text;
1044
+ } else if (prodFunc?.type === 'attribute') {
1045
+ const prodAttr = prodFunc.childForFieldName('attribute');
1046
+ if (prodAttr) {
1047
+ receiverCall = prodAttr.text;
1048
+ receiverCallIsMethod = true;
1049
+ }
1050
+ }
1051
+ }
1052
+ if (!receiverCall) receiverCallAwaited = undefined;
1053
+ }
684
1054
 
685
1055
  // Detect self.X.method() pattern: objNode is attribute access on self/cls
686
1056
  if (objNode?.type === 'attribute') {
@@ -694,15 +1064,33 @@ function findCallsInCode(code, parser) {
694
1064
  }
695
1065
  }
696
1066
 
697
- const receiverType = receiver ? localVarTypes.get(receiver) : undefined;
1067
+ // Literal receivers carry their builtin type: {}.get() can
1068
+ // never be a project class method
1069
+ const receiverType = receiver
1070
+ ? localVarTypes.get(receiver)
1071
+ : (objNode ? PY_LITERAL_RECEIVER_TYPES[objNode.type] : undefined);
1072
+ // Module receiver (httpx.get()) — unless locally shadowed
1073
+ // by a typed instance binding
1074
+ const receiverIsModule = !!receiver && moduleAliases.has(receiver) &&
1075
+ !localVarTypes.has(receiver);
698
1076
  const firstArg = getFirstStringArg(node);
699
1077
  calls.push({
700
1078
  name: attrNode.text,
701
- line: node.startPosition.row + 1,
1079
+ // Multi-line chains (obj.x()\n.y()) must report each
1080
+ // method's OWN name line, not the chain-start line —
1081
+ // the account's ground set is keyed by the name's line
1082
+ line: attrNode.startPosition.row + 1,
702
1083
  isMethod: true,
703
1084
  receiver,
704
1085
  ...(receiverType && { receiverType }),
1086
+ ...(receiverIsModule && { receiverIsModule: true }),
705
1087
  ...(selfAttribute && { selfAttribute }),
1088
+ ...(receiverCall && { receiverCall }),
1089
+ ...(receiverCallIsMethod && { receiverCallIsMethod: true }),
1090
+ ...(receiverCallAwaited && { receiverCallAwaited: true }),
1091
+ ...(assignedTo && { assignedTo }),
1092
+ argCount,
1093
+ ...(argSpread && { argSpread: true }),
706
1094
  enclosingFunction,
707
1095
  uncertain,
708
1096
  ...(firstArg && { firstStringArg: firstArg.value, firstStringArgInterp: firstArg.interp })
@@ -730,6 +1118,7 @@ function findCallsInCode(code, parser) {
730
1118
  isMethod: false,
731
1119
  isFunctionReference: true,
732
1120
  isPotentialCallback: true,
1121
+ ...(isShadowedByLocal(arg, arg.text) && { localShadow: true }),
733
1122
  enclosingFunction
734
1123
  });
735
1124
  }
@@ -747,6 +1136,7 @@ function findCallsInCode(code, parser) {
747
1136
  isMethod: false,
748
1137
  isFunctionReference: true,
749
1138
  isPotentialCallback: true,
1139
+ ...(isShadowedByLocal(val, val.text) && { localShadow: true }),
750
1140
  enclosingFunction
751
1141
  });
752
1142
  }
@@ -770,6 +1160,11 @@ function findCallsInCode(code, parser) {
770
1160
  localVarTypes.clear();
771
1161
  for (const [k, v] of saved) localVarTypes.set(k, v);
772
1162
  }
1163
+ const savedAliases = memberAliasesStack.pop();
1164
+ if (savedAliases) {
1165
+ memberAliases.clear();
1166
+ for (const [k, v] of savedAliases) memberAliases.set(k, v);
1167
+ }
773
1168
  }
774
1169
  }
775
1170
  });
@@ -955,7 +1350,7 @@ function findUsagesInCode(code, name, parser) {
955
1350
  const tree = parseTree(parser, code);
956
1351
  const usages = [];
957
1352
 
958
- traverseTreeCached(tree.rootNode, (node) => {
1353
+ visitNameNodes(tree, code, name, (node) => {
959
1354
  // Only look for identifiers with the matching name
960
1355
  if (node.type !== 'identifier' || node.text !== name) {
961
1356
  return true;