diff --git a/CHANGELOG.md b/CHANGELOG.md index b6930b4e7..a5c3d9eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.48 (2026-08-20) +- Fix: a class that extends one of its own language's built-in exception types no longer inherits from an unrelated same-named class in another language, so a PHP `class FooApiException extends \Exception` stops fusing onto a TypeScript `Exception` class; the unique-stub rewire now refuses that cross-language match and leaves the built-in base on its own external stub, while legitimate cross-language rewires are unchanged (#2812, thanks @moeen-basra). - Fix: a control character in a node label or id no longer aborts the whole export; the GraphML and Obsidian exporters scrub only the characters those formats forbid (tab, newline, and non-ASCII letters are preserved), and `graph.json` and its byte-identity round-trip are untouched (#2897, thanks @abhay-codes07). - Fix: `graphify update` / `label` / `cluster-only` no longer leave a large graph without a `graph.html`; the aggregated community view renders instead of raising, a failed render preserves the previous file, and a missing `graph.html` is regenerated on the no-change fast path without reclustering (#2853, thanks @oleksii-tumanov). - Feature: `graphify extract --no-dedup` skips the fuzzy near-duplicate merge on build and incremental merge, for operators who would rather keep distinct symbols that fuzzy-matched; exact-id uniqueness is unaffected and the flag arms the shrink guard so a surprising node drop is refused loudly (#2881, thanks @rajarshidattapy). diff --git a/graphify/extract.py b/graphify/extract.py index ffc6153f8..e70061116 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2238,6 +2238,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) @@ -2340,6 +2396,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") @@ -2359,7 +2446,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 c9790e4ab..afa87aa8b 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 cb390eebc..db7c0d90e 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( + "