mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 00:19:33 +00:00
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:
1 parent
b5162dc2e5
commit
f57af70f77
3 files changed
+111
-1
No files matched your search
+5
-1
@@ -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"}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user