From 1979f2ebc612cb5da961b5787a30a1478b1ba018 Mon Sep 17 00:00:00 2001 From: abhi-byte62 Date: Thu, 8 Oct 2026 14:41:26 +0530 Subject: [PATCH] fix(extract): qualify nested class node IDs with parent class scope --- graphify/extractors/engine.py | 7 +- tests/test_python_nested_classes.py | 102 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/test_python_nested_classes.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index ddb177f6d2..432459c8dd 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4954,9 +4954,10 @@ def walk(node, parent_class_nid: str | None = None) -> None: ) if _csharp_arity: class_name_for_id = f"{class_name}`{_csharp_arity}" - class_nid = _make_id(stem, ".".join(namespace_stack), class_name_for_id) - if config.ts_module == "tree_sitter_python" and parent_class_nid: - class_nid = _make_id(parent_class_nid, class_name) + if parent_class_nid and config.ts_module != "tree_sitter_ruby": + class_nid = _make_id(parent_class_nid, class_name_for_id) + else: + class_nid = _make_id(stem, ".".join(namespace_stack), class_name_for_id) line = node.start_point[0] + 1 metadata = None ruby_reopened = ( diff --git a/tests/test_python_nested_classes.py b/tests/test_python_nested_classes.py new file mode 100644 index 0000000000..f46daf08c1 --- /dev/null +++ b/tests/test_python_nested_classes.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _nodes_by_id(r: dict) -> dict[str, dict]: + return {n["id"]: n for n in r["nodes"]} + + +def _edges_by_rel(r: dict, rel: str) -> list[tuple[str, str]]: + return [(e["source"], e["target"]) for e in r["edges"] if e.get("relation") == rel] + + +def test_python_same_named_nested_classes_under_different_parents(tmp_path: Path) -> None: + """#4220: nested classes with identical names in different parent classes + must mint distinct node IDs qualified by their parent class and must not + collide or swallow one another.""" + py_file = tmp_path / "scheduler.py" + py_file.write_text( + "class Forward:\n" + " class Params:\n" + " def __init__(self, lr: float):\n" + " self.lr = lr\n" + "\n" + "class Backward:\n" + " class Params:\n" + " def __init__(self, clip: float):\n" + " self.clip = clip\n", + encoding="utf-8", + ) + result = extract([py_file], root=str(tmp_path), cache_root=tmp_path) + nodes = _nodes_by_id(result) + contains_edges = _edges_by_rel(result, "contains") + method_edges = _edges_by_rel(result, "method") + + # Outer classes exist + assert "scheduler_forward" in nodes + assert "scheduler_backward" in nodes + assert ("scheduler", "scheduler_forward") in contains_edges + assert ("scheduler", "scheduler_backward") in contains_edges + + # Nested classes exist as distinct nodes with their own IDs + assert "scheduler_forward_params" in nodes + assert "scheduler_backward_params" in nodes + assert nodes["scheduler_forward_params"]["label"] == "Params" + assert nodes["scheduler_backward_params"]["label"] == "Params" + + # Containment edges source from respective enclosing classes + assert ("scheduler_forward", "scheduler_forward_params") in contains_edges + assert ("scheduler_backward", "scheduler_backward_params") in contains_edges + assert ("scheduler", "scheduler_forward_params") not in contains_edges + assert ("scheduler", "scheduler_backward_params") not in contains_edges + + # Methods attach to their respective nested classes + assert ("scheduler_forward_params", "scheduler_forward_params_init") in method_edges + assert ("scheduler_backward_params", "scheduler_backward_params_init") in method_edges + + +def test_python_deeply_nested_classes(tmp_path: Path) -> None: + """Deeply nested classes (A.B.C) build hierarchical node IDs.""" + py_file = tmp_path / "tree.py" + py_file.write_text( + "class Outer:\n" + " class Middle:\n" + " class Inner:\n" + " def action(self):\n" + " pass\n", + encoding="utf-8", + ) + result = extract([py_file], root=str(tmp_path), cache_root=tmp_path) + nodes = _nodes_by_id(result) + contains = _edges_by_rel(result, "contains") + methods = _edges_by_rel(result, "method") + + assert "tree_outer" in nodes + assert "tree_outer_middle" in nodes + assert "tree_outer_middle_inner" in nodes + assert ("tree", "tree_outer") in contains + assert ("tree_outer", "tree_outer_middle") in contains + assert ("tree_outer_middle", "tree_outer_middle_inner") in contains + assert ("tree_outer_middle_inner", "tree_outer_middle_inner_action") in methods + + +def test_python_same_name_recursive_nesting(tmp_path: Path) -> None: + """Same-named class nesting (class Foo: class Foo) does not collide or create self-loops.""" + py_file = tmp_path / "recursive.py" + py_file.write_text( + "class Node:\n" + " class Node:\n" + " pass\n", + encoding="utf-8", + ) + result = extract([py_file], root=str(tmp_path), cache_root=tmp_path) + nodes = _nodes_by_id(result) + contains = _edges_by_rel(result, "contains") + + assert "recursive_node" in nodes + assert "recursive_node_node" in nodes + assert ("recursive", "recursive_node") in contains + assert ("recursive_node", "recursive_node_node") in contains