mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 09:17:02 +00:00
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:
committed by
safishamsi
parent
0f625b6042
commit
037970159d
+88
-1
@@ -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"))}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user