From 9f0ad48259a0cf7a91c6f986e93b07db38ef359c Mon Sep 17 00:00:00 2001 From: Dev Chiniwala Date: Sun, 13 Sep 2026 15:42:21 +0530 Subject: [PATCH] fix(extract): skip calls to sourceless stubs --- graphify/extract.py | 6 +++ graphify/extractors/engine.py | 9 +++- tests/test_sourceless_call_targets.py | 75 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/test_sourceless_call_targets.py diff --git a/graphify/extract.py b/graphify/extract.py index 90a6ab026..b9442c851 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -7484,6 +7484,12 @@ def extract( if tgt is None: continue has_import_evidence = False + # A source-less candidate is an unresolved external stub, not a + # project-defined callable. It can remain a target of type/reference + # edges, but a name-only call must not turn it into a cross-file calls + # hub (#3156). Source-backed project definitions continue normally. + if not nid_to_source_file.get(tgt): + continue if rc.get("indirect"): # Cross-file indirect dispatch: a callback passed BY NAME # (`from .h import fn; pool.submit(fn)`, or listed in a dispatch diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index aeec2574a..a44538e08 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5861,7 +5861,14 @@ def _extract_generic( and not nid_to_sf.get(tgt_nid) ): tgt_nid = None - if tgt_nid: + # A name-only reference can leave a source-less stub behind when + # the target is outside the scanned corpus (for example, + # ``HTTPException`` imported from FastAPI). It is useful for the + # type/reference edge, but it is not a project-defined callable + # and must not become a calls hub. Real definitions have a + # source_file and continue through the normal call path. + external_stub_target = bool(tgt_nid and not nid_to_sf.get(tgt_nid)) + if tgt_nid and not external_stub_target: pair = (caller_nid, tgt_nid) if pair not in seen_call_pairs: seen_call_pairs.add(pair) diff --git a/tests/test_sourceless_call_targets.py b/tests/test_sourceless_call_targets.py new file mode 100644 index 000000000..e18cac388 --- /dev/null +++ b/tests/test_sourceless_call_targets.py @@ -0,0 +1,75 @@ +"""Regression tests for unresolved external call targets (#3156).""" + +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _nodes(result: dict) -> dict[str, dict]: + return {node["id"]: node for node in result["nodes"]} + + +def _labelled_edges(result: dict, nodes: dict[str, dict], label: str) -> list[dict]: + return [ + edge + for edge in result["edges"] + if nodes.get(edge.get("target"), {}).get("label") == label + ] + + +def test_sourceless_external_call_target_is_not_emitted(tmp_path: Path): + """A type-reference stub must not become a project-wide calls hub.""" + typed = tmp_path / "typed.py" + typed.write_text( + "from fastapi import HTTPException\n" + "\n" + "def typed_error() -> HTTPException:\n" + " return HTTPException(status_code=400)\n", + encoding="utf-8", + ) + caller = tmp_path / "caller.py" + caller.write_text( + "def bare_error():\n" + " return HTTPException(status_code=401)\n", + encoding="utf-8", + ) + + result = extract([typed, caller], cache_root=tmp_path, root=tmp_path) + nodes = _nodes(result) + external = [node for node in nodes.values() if node.get("label") == "HTTPException"] + + assert len(external) == 1 + assert external[0].get("source_file") == "" + references = _labelled_edges(result, nodes, "HTTPException") + assert any(edge.get("context") == "return_type" for edge in references) + assert not any(edge.get("relation") == "calls" for edge in references) + + +def test_source_backed_exception_call_is_preserved(tmp_path: Path): + """A project-defined exception with the same name remains a real call target.""" + definitions = tmp_path / "exceptions.py" + definitions.write_text( + "class HTTPException(Exception):\n" + " pass\n", + encoding="utf-8", + ) + caller = tmp_path / "api.py" + caller.write_text( + "from exceptions import HTTPException\n" + "\n" + "def raise_error():\n" + " return HTTPException()\n", + encoding="utf-8", + ) + + result = extract([definitions, caller], cache_root=tmp_path, root=tmp_path) + nodes = _nodes(result) + calls = _labelled_edges(result, nodes, "HTTPException") + + assert any( + edge.get("relation") == "calls" + and nodes[edge["target"]].get("source_file") == "exceptions.py" + for edge in calls + ), calls