From 037970159d24de20c5bb26a69be35362092deca4 Mon Sep 17 00:00:00 2001 From: ousamabenyounes <2910651+ousamabenyounes@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:26:00 +0100 Subject: [PATCH] fix(extract): stop a built-in base class from inheriting across languages (#2812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class extending a built-in base (PHP `extends \Exception`) emits a sourceless supertype stub, and the corpus rewire bound it to the unique same-labelled real definition anywhere in the corpus — so in a PHP+TS monorepo a PHP class inherited from the TypeScript Exception, a phantom cross-language edge / god node. Gate the supertype-target rewire with a per-language curated builtin-base set: refuse the rewire only when the referring file's language names the base as a builtin and the resolved target is in a different language family. Same-language inheritance and user classes named like a builtin still link. --- graphify/extract.py | 89 ++++++++++++++++++++++++++++++++++++++++- tests/test_extract.py | 68 +++++++++++++++++++++++++++++++ tests/test_multilang.py | 42 +++++++++++++++++++ 3 files changed, 198 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index 30867210..994010c3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2239,6 +2239,62 @@ def _lang_family(source_file: object) -> str | None: return _LANG_FAMILY_BY_EXT.get(Path(str(source_file)).suffix.lower()) +# A language's own built-in throwable hierarchy, keyed by the interop family of +# the file that names it. `class FooApiException extends \Exception` in PHP means +# PHP's global `Exception`, so a same-named class defined in a file of ANOTHER +# family cannot be what it refers to (#2812). Scoped to built-in throwables on +# purpose: they are the names every language ships and every corpus subclasses, +# while a name a corpus commonly defines itself would suppress a real supertype +# edge. `_LANGUAGE_BUILTIN_GLOBALS` covers the adjacent call-target case and is +# deliberately separate — a flat set consulted at call sites, neither per-family +# nor consulted by supertype resolution. +_LANGUAGE_BUILTIN_BASE_CLASSES: dict[str, frozenset[str]] = { + "php": frozenset({ + "Throwable", "Exception", "ErrorException", "Error", "TypeError", + "ValueError", "ArgumentCountError", "ArithmeticError", + "DivisionByZeroError", "RuntimeException", "LogicException", + "InvalidArgumentException", "DomainException", "LengthException", + "OutOfRangeException", "OutOfBoundsException", "RangeException", + "OverflowException", "UnderflowException", "UnexpectedValueException", + "BadFunctionCallException", "BadMethodCallException", "JsonException", + }), + "jvm": frozenset({ + "Throwable", "Exception", "RuntimeException", "Error", + "IllegalArgumentException", "IllegalStateException", + "UnsupportedOperationException", "IndexOutOfBoundsException", + "NullPointerException", "IOException", + }), + "python": frozenset({ + "BaseException", "Exception", "ValueError", "TypeError", "KeyError", + "IndexError", "RuntimeError", "NotImplementedError", "AttributeError", + "OSError", "IOError", "StopIteration", "Warning", "UserWarning", + "DeprecationWarning", + }), + "jsts": frozenset({ + "Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", + "EvalError", "URIError", "AggregateError", + }), + "dotnet": frozenset({ + "Exception", "ApplicationException", "SystemException", + "ArgumentException", "ArgumentNullException", + "ArgumentOutOfRangeException", "InvalidOperationException", + "NotImplementedException", "NotSupportedException", + }), + "ruby": frozenset({ + "Exception", "StandardError", "RuntimeError", "ArgumentError", + "TypeError", "NameError", "NoMethodError", "IOError", + }), +} + +# Folded companion, for referrers whose language resolves identifiers +# case-insensitively (#1581) — PHP `extends \exception` names the same built-in +# as `extends \Exception`. Mirrors the `real_by_label` / `real_by_label_ci` pair. +_LANGUAGE_BUILTIN_BASE_CLASSES_CI: dict[str, frozenset[str]] = { + family: frozenset(name.lower() for name in names) + for family, names in _LANGUAGE_BUILTIN_BASE_CLASSES.items() +} + + 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) @@ -2341,6 +2397,37 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: by_id = {node.get("id"): node for node in nodes if node.get("id")} csharp_scoped_relations = {"inherits", "implements", "references", "imports"} + + def _names_own_builtin_base(edge: dict, stub_id: str, remapped_id: str) -> bool: + r"""#2812: `class FooApiException extends \Exception` names PHP's own global + built-in, so a same-named class defined in another language cannot be what + it refers to — yet the bare name was scoped by nothing and the unique + TypeScript `Exception` absorbed the stub, leaving a PHP class inheriting + from a TS one. + + Decided per EDGE rather than per stub: one sourceless `Exception` stub + collects referrers from every language that names it, and the TypeScript + referrers must still rewire onto the TypeScript class. + + Deliberately narrower than a blanket family gate on the type path: a + corpus really can declare its own `BookStore` in one language and subclass + it from another (`test_extract_rewires_unique_inheritance_stub_to_real_definition`). + """ + if edge.get("relation") not in _SUPERTYPE_RELATIONS: + return False + edge_fam = _lang_family(edge.get("source_file")) + if edge_fam is None: + return False + label = str(by_id.get(stub_id, {}).get("label", "")).strip() + if _lang_is_case_insensitive(edge.get("source_file")): + builtins = _LANGUAGE_BUILTIN_BASE_CLASSES_CI.get(edge_fam, frozenset()) + label = label.lower() + else: + builtins = _LANGUAGE_BUILTIN_BASE_CLASSES.get(edge_fam, frozenset()) + if label not in builtins: + return False + target_fam = _lang_family(by_id.get(remapped_id, {}).get("source_file")) + return target_fam is not None and target_fam != edge_fam for edge in edges: is_csharp_scoped_edge = ( str(edge.get("source_file", "")).endswith(".cs") @@ -2360,7 +2447,7 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None: if not ( is_csharp_scoped_edge and str(by_id.get(remapped_target, {}).get("source_file", "")).endswith(".cs") - ): + ) and not _names_own_builtin_base(edge, str(target), remapped_target): edge["target"] = remapped_target referenced = {x for e in edges for x in (e.get("source"), e.get("target"))} diff --git a/tests/test_extract.py b/tests/test_extract.py index c9790e4a..afa87aa8 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -3819,6 +3819,74 @@ def test_rewire_does_not_bind_supertype_stub_to_function(): assert edges[0]["target"] == "BookStore" # inherits stub not bound to function +def test_rewire_does_not_bind_supertype_stub_across_language(): + """#2812: a bare `extends Exception` in PHP is the language's own built-in. + It must not fuse onto a unique same-named TypeScript class.""" + from graphify.extract import _rewire_unique_stub_nodes + nodes = [ + {"id": "app_exception_Exception", "label": "Exception", "file_type": "code", + "source_file": "app/exception.ts", "source_location": "L1"}, + {"id": "Exception", "label": "Exception", "file_type": "code", "source_file": ""}, + ] + edges = [{"source": "pkg_FooApiException", "target": "Exception", "relation": "inherits", + "source_file": "pkg/FooApiException.php", "weight": 1.0}] + _rewire_unique_stub_nodes(nodes, edges) + assert edges[0]["target"] == "Exception" # unchanged — cross-language blocked + assert "Exception" in {n["id"] for n in nodes} # stub kept as the external base + + +def test_rewire_binds_builtin_named_supertype_stub_within_same_language(): + """#2812 control: the guard is per language family, not a name blocklist — a + PHP corpus that declares its own `Exception` must still absorb the stub.""" + from graphify.extract import _rewire_unique_stub_nodes + nodes = [ + {"id": "pkg_support_Exception", "label": "Exception", "file_type": "code", + "source_file": "pkg/Support/Exception.php", "source_location": "L1"}, + {"id": "Exception", "label": "Exception", "file_type": "code", "source_file": ""}, + ] + edges = [{"source": "pkg_FooApiException", "target": "Exception", "relation": "inherits", + "source_file": "pkg/FooApiException.php", "weight": 1.0}] + _rewire_unique_stub_nodes(nodes, edges) + assert edges[0]["target"] == "pkg_support_Exception" + + +def test_rewire_builtin_supertype_guard_folds_case_insensitive_languages(): + """#2812: PHP resolves class names case-insensitively, so `extends \\exception` + names the same built-in as `extends \\Exception` and must be blocked too.""" + from graphify.extract import _rewire_unique_stub_nodes + nodes = [ + {"id": "app_exception_exception", "label": "exception", "file_type": "code", + "source_file": "app/exception.ts", "source_location": "L1"}, + {"id": "exception", "label": "exception", "file_type": "code", "source_file": ""}, + ] + edges = [{"source": "pkg_FooApiException", "target": "exception", "relation": "inherits", + "source_file": "pkg/FooApiException.php", "weight": 1.0}] + _rewire_unique_stub_nodes(nodes, edges) + assert edges[0]["target"] == "exception" + + +def test_rewire_builtin_supertype_guard_is_per_edge_not_per_stub(): + """#2812: one sourceless `Exception` stub collects referrers from every + language that names it. A TypeScript referrer sharing the stub must not + re-open the cross-language bind for the PHP one — the guard reads the + referring file, not the union of the stub's referrer families.""" + from graphify.extract import _rewire_unique_stub_nodes + nodes = [ + {"id": "app_exception_Exception", "label": "Exception", "file_type": "code", + "source_file": "app/exception.ts", "source_location": "L1"}, + {"id": "Exception", "label": "Exception", "file_type": "code", "source_file": ""}, + ] + edges = [ + {"source": "pkg_FooApiException", "target": "Exception", "relation": "inherits", + "source_file": "pkg/FooApiException.php", "weight": 1.0}, + {"source": "app_http_HttpError", "target": "Exception", "relation": "inherits", + "source_file": "app/http.ts", "weight": 1.0}, + ] + _rewire_unique_stub_nodes(nodes, edges) + assert edges[0]["target"] == "Exception" # PHP still blocked + assert edges[1]["target"] == "app_exception_Exception" # TS still resolves + + def test_extract_emits_posix_source_file_for_relative_inputs(tmp_path): r"""source_file must be canonical POSIX on every node AND edge, whatever separator the caller's input paths used. diff --git a/tests/test_multilang.py b/tests/test_multilang.py index cb390eeb..db7c0d90 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -562,6 +562,48 @@ def test_sql_subquery_cte_does_not_suppress_outer_real_table(tmp_path): "outer reference to the real t2 table was wrongly suppressed" ) +def test_php_builtin_base_class_never_inherits_from_cross_language_class(tmp_path): + """#2812: `class FooApiException extends \\Exception` names PHP's global + built-in. The sourceless stub it mints was unique corpus-wide, so the rewire + bound it to an unrelated TypeScript `Exception` class and the PHP class + inherited across languages. No supertype edge may target a TypeScript node.""" + ts = tmp_path / "app" / "exception.ts" + ts.parent.mkdir(parents=True) + ts.write_text( + "export class Exception extends Error {\n" + " constructor(message: string) { super(message); }\n" + "}\n" + ) + php = tmp_path / "packages" / "FooApiException.php" + php.parent.mkdir(parents=True) + php.write_text( + "