From dafdbec33b0b8f4c35b848f35527dce4e84d07de Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Mon, 12 Oct 2026 22:41:18 +0530 Subject: [PATCH] fix(extract): drop Python `Self` type annotation from references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `-> Self` / `: Self` / `typing.Self` annotation (PEP 673) names the enclosing class, not a corpus symbol, but the Python type-ref pass treated the bare identifier `Self` as a type reference and ran it through the sourceless-stub path. That fabricated a node with the bare id `self` that every `Self`-annotated method in the corpus collapses onto — a god node, the same keyword-type pitfall Rust's `Self` was fixed for. `Self` now sits with the other typing special forms in the type-container set, so it is skipped the way `None`/`Ellipsis`/`ClassVar` already are; this also covers the `typing.Self` attribute spelling. Dropping it matches how the `-> "ClassName"` self forward-ref is already left unresolved. A genuine user return type is unaffected. (cherry picked from commit b67f05408edbcebbbcc71f67a99cceb39653c7a6) --- graphify/extractors/engine.py | 7 +++ tests/test_python_self_type_ref.py | 78 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/test_python_self_type_ref.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index d3aef8ce2..150bf1142 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -64,6 +64,13 @@ _PYTHON_TYPE_CONTAINERS = frozenset({ "Generator", "AsyncGenerator", "ContextManager", "AsyncContextManager", "Annotated", "ClassVar", "Final", "Literal", "Concatenate", "ParamSpec", "TypeVar", "None", "Ellipsis", + # PEP 673: `Self` names the enclosing class, not a corpus symbol. Emitting a + # ref mints a sourceless stub with the bare id `self` that every `-> Self` + # method in the corpus collapses onto (a god node), the same keyword-type + # pitfall as Rust's `Self`. Dropping it matches how the `-> "ClassName"` + # self forward-ref is already left unresolved. `typing.Self` resolves to the + # same tail and is covered by the attribute branch below. + "Self", }) _PYTHON_ANNOTATION_NOISE = frozenset({ diff --git a/tests/test_python_self_type_ref.py b/tests/test_python_self_type_ref.py new file mode 100644 index 000000000..1e509e64b --- /dev/null +++ b/tests/test_python_self_type_ref.py @@ -0,0 +1,78 @@ +"""PEP 673 `Self` must not mint a sourceless god node. + +A `-> Self` (or `: Self`, or `typing.Self`) annotation names the enclosing +class, not a corpus symbol. The Python type-ref pass treated the bare +identifier `Self` as a type reference and ran it through the sourceless-stub +path, fabricating a node with the bare id `self` that every `Self`-annotated +method in the corpus collapses onto — the same keyword-type god node Rust's +`Self` was fixed for. It is now dropped, matching how the `-> "ClassName"` +self forward-ref is already left unresolved. +""" +from pathlib import Path + +from graphify.extract import extract + + +def _sourceless(r): + return [(n["id"], n.get("label")) for n in r["nodes"] if not n.get("source_file")] + + +def test_self_return_annotation_mints_no_stub(tmp_path): + (tmp_path / "b.py").write_text( + "from typing import Self\n" + "class Builder:\n" + " def set(self, x) -> Self:\n" + " return self\n" + ) + r = extract([tmp_path / "b.py"], cache_root=tmp_path, parallel=False) + assert _sourceless(r) == [] + assert not any(n["id"] == "self" for n in r["nodes"]) + + +def test_typing_qualified_self_mints_no_stub(tmp_path): + (tmp_path / "b.py").write_text( + "import typing\n" + "class Node:\n" + " def clone(self) -> typing.Self:\n" + " return self\n" + " def link(self, other: typing.Self) -> None:\n" + " pass\n" + ) + r = extract([tmp_path / "b.py"], cache_root=tmp_path, parallel=False) + assert _sourceless(r) == [] + + +def test_self_is_not_a_cross_file_god_node(tmp_path): + (tmp_path / "a.py").write_text( + "from typing import Self\n" + "class A:\n" + " def f(self) -> Self:\n" + " return self\n" + ) + (tmp_path / "b.py").write_text( + "from typing import Self\n" + "class B:\n" + " def g(self) -> Self:\n" + " return self\n" + ) + r = extract(sorted(tmp_path.glob("*.py")), cache_root=tmp_path, parallel=False) + assert not any(n["id"] == "self" for n in r["nodes"]) + assert not any(e.get("target") == "self" for e in r["edges"]) + + +def test_real_return_type_still_referenced(tmp_path): + # The guard must not suppress a genuine user return type. + (tmp_path / "m.py").write_text( + "class Foo:\n" + " pass\n" + "class B:\n" + " def make(self) -> Foo:\n" + " return Foo()\n" + ) + r = extract([tmp_path / "m.py"], cache_root=tmp_path, parallel=False) + foo_ids = [n["id"] for n in r["nodes"] if n.get("label") == "Foo"] + assert foo_ids + assert any( + e.get("relation") == "references" and e.get("target") in foo_ids + for e in r["edges"] + )