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.
This commit is contained in:
abhay-codes07 authored and safishamsi committed 2026-08-29 16:15:01 +01:00
1 parent b5162dc2e5
commit f57af70f77
3 files changed
+111 -1

No files matched your search

+5 -1
View File
@@ -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"}),
+16
View File
@@ -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
+90
View File
@@ -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 = "<?php\nnamespace App;\nclass Foo { public function __construct() {} }\n"
BAR = "<?php\nnamespace App;\nclass Bar { public function __construct() {} }\n"
def test_new_in_assignment_links_to_the_constructed_class(tmp_path):
calls, _ = _extract(tmp_path, {"Foo.php": FOO, "Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function run(): void { $a = new Foo(); }\n}\n")})
assert (".run()", "Foo") in calls
def test_new_in_argument_position_links_the_message_bus_shape(tmp_path):
"""`$bus->dispatch(new Bar(2))` - construction as control flow."""
calls, _ = _extract(tmp_path, {"Bar.php": BAR, "Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function run($bus): void { $bus->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": (
"<?php\nclass Caller {\n"
" public function run(): void { $b = new \App\Bar(); }\n}\n")})
assert (".run()", "Bar") in calls
def test_dynamic_and_self_construction_produce_no_junk(tmp_path):
"""`new $cls()` names nothing; `new self()` / `new static()` name no OTHER
class - none may mint an edge to a phantom."""
calls, r = _extract(tmp_path, {"Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function a($cls) { return new $cls(); }\n"
" public function b() { return new self(); }\n"
" public function c() { return new static(); }\n}\n")})
labels = {n["label"] for n in r["nodes"]}
assert not any(t in ("self", "static", "$cls") for _s, t in calls)
assert "self" not in labels and "static" not in labels
def test_existing_static_call_edges_are_unchanged(tmp_path):
calls, _ = _extract(tmp_path, {"Baz.php": (
"<?php\nnamespace App;\nclass Baz {\n"
" public static function create(): self { return new self(); }\n}\n"),
"Caller.php": (
"<?php\nnamespace App;\nclass Caller {\n"
" public function run() { return Baz::create(); }\n}\n")})
assert any(s == ".run()" and "Baz" in t for s, t in calls)
def test_cross_file_dispatcher_reaches_the_command_class(tmp_path):
calls, _ = _extract(tmp_path, {
"Command/AttachRecord.php": (
"<?php\nnamespace App\Command;\n"
"class AttachRecord { public function __construct(public int $id) {} }\n"),
"Controller/Records.php": (
"<?php\nnamespace App\Controller;\nuse App\Command\AttachRecord;\n"
"class Records {\n"
" public function attach($bus): void { $bus->dispatch(new AttachRecord(7)); }\n}\n"),
})
assert (".attach()", "AttachRecord") in calls