fix(extract): stop a built-in base class from inheriting across languages (#2812)

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.
This commit is contained in:
ousamabenyounes
2026-08-21 18:26:00 +01:00
committed by safishamsi
parent 0f625b6042
commit 037970159d
3 changed files with 198 additions and 1 deletions
+88 -1
View File
@@ -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"))}
+68
View File
@@ -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.
+42
View File
@@ -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(
"<?php\n"
"\n"
"namespace Foo\\Exceptions;\n"
"\n"
"class FooApiException extends \\Exception\n"
"{\n"
"}\n"
)
r = extract([php, ts], root=tmp_path)
ts_nodes = {n["id"] for n in r["nodes"]
if str(n.get("source_file", "")).endswith(".ts")}
supertypes = [e for e in r["edges"]
if e["relation"] in ("inherits", "implements", "extends")
and str(e.get("source_file", "")).endswith(".php")]
assert supertypes, "PHP inheritance was not extracted at all"
for e in supertypes:
assert e["target"] not in ts_nodes, (
f"PHP supertype leaked cross-language: {e}"
)
# The base stays on its sourceless external stub rather than vanishing.
sourceless = {n["id"] for n in r["nodes"] if not n.get("source_file")}
assert any(e["target"] in sourceless for e in supertypes), (
f"PHP base class lost its external stub: {supertypes}"
)
def test_sql_cte_never_binds_to_cross_language_symbol(tmp_path):
"""#2577: the reported leak — the CTE's sourceless stub was unique corpus-wide,
so _rewire_unique_stub_nodes bound it to a same-named symbol from ANOTHER