diff --git a/graphify/extract.py b/graphify/extract.py index 9242105ec..8064e9150 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3465,7 +3465,11 @@ _LANGUAGE_BUILTIN_BASE_CLASSES_CI: dict[str, frozenset[str]] = { def _node_label_key(node: dict, fold: bool = False) -> str: label = str(node.get("label", "")).strip() - key = re.sub(r"[^a-zA-Z0-9]+", "", label) + # Keep underscores: they are significant identifier characters, so a private + # `_Response` must not share a key with an external `Response` and absorb its + # reference during stub rewiring (#4269). Only drop call/generic punctuation + # (`()`, `<>`, `.`, whitespace) so `Foo()` and `Foo` still match. + key = re.sub(r"[^a-zA-Z0-9_]+", "", label) return key.lower() if fold else key diff --git a/tests/test_qualified_annotation_stub_bind.py b/tests/test_qualified_annotation_stub_bind.py new file mode 100644 index 000000000..43f23277c --- /dev/null +++ b/tests/test_qualified_annotation_stub_bind.py @@ -0,0 +1,75 @@ +"""Regression for #4269: a qualified external annotation must not bind to an +unrelated private local class through the stub-rewire underscore collision. + +`def read_body(response: httpx.Response)` names an external type. A private +`class _Response` defined in a test file used to absorb that reference because +the stub-rewire key stripped the leading underscore, making `_Response` and +`Response` compare equal. The result was a fabricated, EXTRACTED-confidence +edge from production code to test code. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.build import build_from_json +from graphify.extract import extract + + +def _edge_set(G): + return { + (d.get("relation"), d.get("_src", u), d.get("_tgt", v)) + for u, v, d in G.edges(data=True) + } + + +def test_qualified_annotation_does_not_bind_to_private_local_class(tmp_path: Path) -> None: + (tmp_path / "app").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "app" / "client.py").write_text( + "import httpx\n\n\n" + "def read_body(response: httpx.Response) -> bytes:\n" + " return response.content\n", + encoding="utf-8", + ) + (tmp_path / "tests" / "test_fake.py").write_text( + "class _Response:\n" + ' """Stand-in for a urllib response."""\n\n' + " def read(self):\n" + ' return b""\n', + encoding="utf-8", + ) + + paths = [tmp_path / "app" / "client.py", tmp_path / "tests" / "test_fake.py"] + res = extract(paths, root=tmp_path, parallel=False, cache_root=tmp_path / "graphify-cache") + G = build_from_json(res, root=str(tmp_path), directed=True) + edges = _edge_set(G) + + # The fabricated production -> test edge must be gone. + assert ("references", "app_client_read_body", "tests_test_fake_response") not in edges, ( + f"httpx.Response annotation falsely bound to the private local _Response: {edges}" + ) + # The private class must still exist as its own node, untouched. + assert "tests_test_fake_response" in G + + +def test_bare_underscore_class_does_not_absorb_sibling_reference(tmp_path: Path) -> None: + """Minimal rewire control: a sourceless `Thing` reference must not rewire to a + located `_Thing`; underscores are identifier-significant.""" + (tmp_path / "producer.py").write_text( + "import external\n\n\n" + "def build(x: external.Thing) -> None:\n" + " return None\n", + encoding="utf-8", + ) + (tmp_path / "private.py").write_text( + "class _Thing:\n" + " pass\n", + encoding="utf-8", + ) + paths = [tmp_path / "producer.py", tmp_path / "private.py"] + res = extract(paths, root=tmp_path, parallel=False, cache_root=tmp_path / "graphify-cache") + G = build_from_json(res, root=str(tmp_path), directed=True) + edges = _edge_set(G) + assert ("references", "producer_build", "private_thing") not in edges, ( + f"`external.Thing` reference falsely absorbed by the private `_Thing`: {edges}" + )