Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
89 changes: 88 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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"))}
Expand Down
68 changes: 68 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions tests/test_multilang.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading