From f57af70f77afa2ac09b9fc02cac31f5d9121372b Mon Sep 17 00:00:00 2001 From: abhay-codes07 <182421137+abhay-codes07@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:15:01 +0100 Subject: [PATCH] fix(php): emit a calls edge for object creation, new Foo(...) (#3115) A PHP `new Foo()` emitted no edge. Add object_creation_expression to the PHP call types and emit a calls edge to the constructed class (namespaced names resolve to the last segment), consistent with the C# (#2998) and TypeScript (#3135) object-creation handling. Dynamic (new $var()), self/static/parent, and unknown external constructions fabricate nothing. --- graphify/extract.py | 6 ++- graphify/extractors/engine.py | 16 ++++++ tests/test_php_object_creation.py | 90 +++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/test_php_object_creation.py diff --git a/graphify/extract.py b/graphify/extract.py index 62c2f7567..3477fa788 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -997,7 +997,11 @@ _PHP_CONFIG = LanguageConfig( class_types=frozenset({"class_declaration"}), function_types=frozenset({"function_definition", "method_declaration"}), import_types=frozenset({"namespace_use_clause"}), - call_types=frozenset({"function_call_expression", "member_call_expression", "scoped_call_expression", "class_constant_access_expression"}), + # object_creation_expression joins the dispatch set so `new Foo(...)` links + # the constructing method to Foo (engine has a dedicated PHP branch: the + # class sits in a bare name/qualified_name child, not a field) - the PHP + # twin of Java #1373 / C# #2997 (#3115). + call_types=frozenset({"function_call_expression", "member_call_expression", "scoped_call_expression", "class_constant_access_expression", "object_creation_expression"}), static_prop_types=frozenset({"scoped_property_access_expression"}), helper_fn_names=frozenset({"config"}), container_bind_methods=frozenset({"bind", "singleton", "scoped", "instance"}), diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 36ca188e6..dcb21963a 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5183,6 +5183,22 @@ def _extract_generic( if child.type == "identifier": callee_name = _read_text(child, source) break + elif config.ts_module == "tree_sitter_php" and node.type == "object_creation_expression": + # PHP `new Foo(...)` keeps the class in a bare name/qualified_name + # child (no field), so the generic call path never names it and a + # class a method only constructs stayed unlinked - the PHP twin of + # Java's #1373 and C#'s #2997. The types this loses are the ones + # handed straight to something else (`$bus->dispatch(new Cmd(...))`). + # A qualified `new \App\Bar()` names the last segment, matching how + # the PHP namespace pass keys classes; `new $cls()` is dynamic and + # `new self()`/`static`/`parent` name no other class - all stay + # unnamed rather than minting junk edges. + for _oce_child in node.children: + if _oce_child.type in ("name", "qualified_name"): + _oce_text = _read_text(_oce_child, source).rsplit("\\", 1)[-1] + if _oce_text.lower() not in ("self", "static", "parent"): + callee_name = _oce_text + break elif config.ts_module == "tree_sitter_c_sharp" and node.type == "object_creation_expression": # `new Foo(...)` keeps the constructed type in the `type` field, so # the invocation path below never sees it and a type a method only diff --git a/tests/test_php_object_creation.py b/tests/test_php_object_creation.py new file mode 100644 index 000000000..f77748d6b --- /dev/null +++ b/tests/test_php_object_creation.py @@ -0,0 +1,90 @@ +"""PHP `new Foo(...)` links the constructing method to Foo (#3115). + +`object_creation_expression` was absent from `_PHP_CONFIG.call_types`, so a +class a method merely constructs got no edge at all. Java has taken +`new Foo(...)` as a call since #1373 and C# since #2997; PHP never caught up. +On the reporter's Symfony corpus 505 `new X(` sites across 178 classes were +invisible - worst on message-bus code, where construction IS the control flow +(`$bus->dispatch(new SomeCommand(...))`: 0 of 22 sites had any edge). +""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body, encoding="utf-8") + r = extract([tmp_path / n for n in files], cache_root=Path(tempfile.mkdtemp()), + root=tmp_path, parallel=False) + labels = {n["id"]: n["label"] for n in r["nodes"]} + calls = {(labels[e["source"]], labels[e["target"]]) for e in r["edges"] + if e["relation"] == "calls"} + return calls, r + + +FOO = "dispatch(new Bar(2))` - construction as control flow.""" + calls, _ = _extract(tmp_path, {"Bar.php": BAR, "Caller.php": ( + "dispatch(new Bar(2)); }\n}\n")}) + assert (".run()", "Bar") in calls + + +def test_qualified_new_names_the_last_segment(tmp_path): + calls, _ = _extract(tmp_path, {"Bar.php": BAR, "Caller.php": ( + "dispatch(new AttachRecord(7)); }\n}\n"), + }) + assert (".attach()", "AttachRecord") in calls