From 1247bae63535895801a681dc94ca17cee6c5bad6 Mon Sep 17 00:00:00 2001 From: Synvoya <16019863+Synvoya@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:11:22 +1000 Subject: [PATCH] fix(extract): emit inherits edge for Python parameterized bases The Python heritage handler in _extract_generic only matched bare `identifier` bases in a class's `superclasses` list. A parameterized base -- `class Repo(Base[int])`, `class C(Generic[T])` -- parses as a `subscript` node whose `value` field is the actual base type and whose `[...]` holds only type parameters. That node type fell through the identifier-only check, so every generic base silently lost its `inherits` edge. Unwrap a `subscript` base to its `value` before the identifier check, so parameterized bases point their heritage edge at the base type. Bare-identifier bases are unaffected. Qualified (`attribute`) bases remain handled by the cross-file symbol resolver and are out of scope here. Regression test in tests/test_languages.py fails before, passes after. --- graphify/extractors/engine.py | 12 ++++++++++-- tests/test_languages.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 90c95cc49..013e8923a 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2344,8 +2344,16 @@ def walk(node, parent_class_nid: str | None = None) -> None: args = node.child_by_field_name("superclasses") if args: for arg in args.children: - if arg.type == "identifier": - base = _read_text(arg, source) + base_node = arg + # A parameterized base (`Base[T]`, `Generic[T]`, + # `Repo[User]`) parses as a `subscript` whose `value` + # field is the actual base type; the `[...]` holds only + # type parameters. Unwrap to the value so the heritage + # edge points at the base instead of being dropped. + if base_node.type == "subscript": + base_node = base_node.child_by_field_name("value") or base_node + if base_node.type == "identifier": + base = _read_text(base_node, source) base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, "inherits", line) diff --git a/tests/test_languages.py b/tests/test_languages.py index f610fbc40..a08480662 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest from graphify.extract import ( + extract_python, extract_java, extract_c, extract_cpp, extract_ruby, extract_csharp, extract_kotlin, extract_scala, extract_php, extract_swift, extract_go, extract_julia, extract_js, extract_fortran, @@ -333,6 +334,33 @@ def test_ruby_inherits_edge(): assert found, "TimeoutApiClient should have inherits edge to ApiClient" +# ── Python ────────────────────────────────────────────────────────────────── + +def test_python_parameterized_base_inherits_edge(tmp_path): + """`class C(Base[T])` must still emit an inherits edge to the base type. + + The Python heritage handler only matched bare `identifier` bases in the + `superclasses` list. A parameterized base parses as a `subscript` node + (its `value` field is the base type; the `[...]` holds only type params), + so it fell through and the heritage edge was silently dropped -- every + `Generic[T]` / `Repo[User]` base lost its `inherits` edge. + """ + source = tmp_path / "heritage.py" + source.write_text( + "class Base: pass\n" + "class Mixin: pass\n" + "class Repo(Base[int]): pass\n" + "class Multi(Mixin, Base[str]): pass\n" + ) + result = extract_python(source) + inherits = _edge_labels(result, "inherits") + # Parameterized base must resolve to the base type, not be dropped. + assert ("Repo", "Base") in inherits + assert ("Multi", "Base") in inherits + # Bare-identifier bases must keep working (regression guard). + assert ("Multi", "Mixin") in inherits + + # ── C# ─────────────────────────────────────────────────────────────────────── def test_csharp_no_error():