From 56634ac22dae9ddcf0b90022d98ec01f38c5174f Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Thu, 8 Oct 2026 16:09:56 +0530 Subject: [PATCH] fix(python): find a nested function's file in the typed-receiver visibility check The typed-receiver arm of _resolve_python_member_calls checks that the receiver's class is defined in or imported into the caller's file. _file_node returned the caller's first contains/method parent, which is the file for a top-level function or method but the enclosing function for a nested def and the outer class for a nested class's method, so those calls (click's `def callback(ctx: Context, ...): ctx.abort()`) never got an edge. Climb the parents to the top, which is always the file node. 0.9.80 vs this change: click +10, rich +4 call edges, all 14 confirmed by jedi at the call site, 0 lost; httpx, flask and typer unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf --- graphify/extract.py | 11 ++++++++--- tests/test_python_typed_receiver_calls.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 0d3bbb2b7f..c4e58d4002 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4245,12 +4245,17 @@ def _module_stem_key(nid: str) -> str: method_class[tgt] = src def _file_node(nid: str) -> "str | None": + # Climb `contains` / `method` parents to the top, which is the file: a + # nested function's `contains` parent is its enclosing function, not the + # file, so stopping at the first parent compared a function to a file. seen: set[str] = set() + parent: "str | None" = None while nid and nid not in seen: seen.add(nid) - if nid in file_of_node: - return file_of_node[nid] - nid = method_class.get(nid, "") + up = file_of_node.get(nid) or method_class.get(nid) + if not up: + return parent + parent = nid = up return None def _emit_call( diff --git a/tests/test_python_typed_receiver_calls.py b/tests/test_python_typed_receiver_calls.py index b02ed6fd29..24d5e0e2fc 100644 --- a/tests/test_python_typed_receiver_calls.py +++ b/tests/test_python_typed_receiver_calls.py @@ -144,6 +144,27 @@ def test_class_not_visible_from_caller_emits_no_edge(tmp_path): assert not _has(calls, "run") +def test_nested_functions_and_nested_class_methods_reach_the_file(tmp_path): + # A nested def's `contains` parent is its enclosing function, and a nested + # class's is its outer class: the visibility check must climb to the file. + calls, _, _ = _calls(tmp_path, { + "client.py": _CLIENT, + "use.py": ( + "from .client import Client\n\n" + "def outer():\n def inner(c: Client):\n return c.send()\n return inner\n\n" + "class K:\n def run(self):\n def callback(c: Client):\n return c.send()\n" + " return callback\n\n" + "def a():\n def b():\n def deep():\n c = Client()\n return c.send()\n" + " return deep\n return b\n\n" + "class Outer:\n class Inner:\n def go(self, c: Client):\n return c.send()\n\n" + "def hidden():\n def nope(c: 'Unknown'):\n return c.send()\n return nope\n" + ), + }) + for caller in ("inner", "callback", "deep", ".go"): # methods are labeled .name() + assert _has(calls, caller), caller + assert not _has(calls, "nope") + + def test_duplicate_class_name_emits_no_edge(tmp_path): calls, _, _ = _calls(tmp_path, { "a/__init__.py": "",