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": "",