mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
fix(php): handle case-insensitivity and namespace qualification in static-call resolution
Addresses 3 findings from the automated review on PR #3874: - Fully-qualified scopes (\App\Models\User::method() or App\Models\User::method()) were left parked as unresolved: the class lookup keyed on the full scope text, which never matches a class node's own unqualified label. Now normalizes to the last namespace segment before lookup. - Method names were matched case-sensitively even though PHP method calls are case-insensitive; a call spelled with different case than the declaration silently produced no edge. The lookup key is now lowercased on both sides. - A lowercase-first class name (legal PHP, just non-PSR) fell through the `receiver[:1].isupper()` gate into "dynamic scope" and was dropped entirely. The gate is now `not receiver.startswith("$")`, since a `$var::` scope is the only case actually requiring the declared-type table this resolver doesn't have.
This commit is contained in:
1 parent
2ec460168b
commit
7ed0e1d70a
2 files changed
+65
-10
No files matched your search
+16
-10
@@ -4570,7 +4570,7 @@ def _resolve_php_member_calls(
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Resolve PHP ``ClassName::method()`` / ``self::``/``static::``/``parent::``
|
||||
r"""Resolve PHP ``ClassName::method()`` / ``self::``/``static::``/``parent::``
|
||||
static calls against the receiver's class (#3872).
|
||||
|
||||
The shared engine's PHP branch used to treat ``scoped_call_expression`` as a
|
||||
@@ -4586,14 +4586,20 @@ def _resolve_php_member_calls(
|
||||
|
||||
``self::``/``static::`` resolve to the enclosing class (exact — the keyword
|
||||
names it as unambiguously as a literal class name would). ``parent::``
|
||||
resolves through a single `inherits` edge from the enclosing class. Any
|
||||
other (lowercase, i.e. array/variable) scope is left unresolved — PHP has no
|
||||
declared-type table for `$var::method()` dynamic scopes to resolve against,
|
||||
and guessing would risk the same over-connection the #543/#1219 guard
|
||||
exists to prevent.
|
||||
resolves through a single `inherits` edge from the enclosing class. A
|
||||
``$var::method()`` dynamic scope is left unresolved — PHP has no
|
||||
declared-type table for it to resolve against, and guessing would risk the
|
||||
same over-connection the #543/#1219 guard exists to prevent. Every other
|
||||
scope is treated as a class reference regardless of case (PHP class and
|
||||
method names are case-insensitive) or namespace qualification (a
|
||||
fully-qualified ``\App\Models\User::method()`` or ``App\Models\User::``
|
||||
matches the class's own unqualified label by its last segment).
|
||||
"""
|
||||
def key(label: str) -> str:
|
||||
return str(label).strip().removeprefix(".").removesuffix("()")
|
||||
return str(label).strip().removeprefix(".").removesuffix("()").lower()
|
||||
|
||||
def class_key(name: str) -> str:
|
||||
return str(name).strip().lstrip("\\").rsplit("\\", 1)[-1].lower()
|
||||
|
||||
contained = {edge.get("target") for edge in all_edges
|
||||
if edge.get("relation") == "contains"}
|
||||
@@ -4606,7 +4612,7 @@ def _resolve_php_member_calls(
|
||||
and node.get("id") in contained
|
||||
and _is_type_like_definition(node)
|
||||
):
|
||||
type_def_nids.setdefault(key(node.get("label", "")), []).append(node["id"])
|
||||
type_def_nids.setdefault(class_key(node.get("label", "")), []).append(node["id"])
|
||||
|
||||
method_index: dict[tuple[str, str], set[str]] = {}
|
||||
enclosing_type: dict[str, str] = {}
|
||||
@@ -4648,8 +4654,8 @@ def _resolve_php_member_calls(
|
||||
if len(bases) != 1:
|
||||
continue
|
||||
type_nid = bases[0]
|
||||
elif receiver[:1].isupper():
|
||||
type_defs = type_def_nids.get(key(receiver), [])
|
||||
elif not receiver.startswith("$"):
|
||||
type_defs = type_def_nids.get(class_key(receiver), [])
|
||||
if not type_defs:
|
||||
_park_unresolved_member_call(
|
||||
node_by_id.get(caller), callee, receiver, "php", raw_call,
|
||||
|
||||
@@ -79,6 +79,55 @@ def test_dynamic_scope_produces_no_phantom_edge(tmp_path):
|
||||
assert not any(s == ".run()" for s, _t in calls)
|
||||
|
||||
|
||||
def test_lowercase_class_name_still_resolves(tmp_path):
|
||||
"""PHP class names are conventionally StudlyCase but the language does not
|
||||
enforce it - a lowercase-first class is still a class, not a variable, and
|
||||
must resolve the same way (review finding on PR #3874)."""
|
||||
calls, _ = _extract(tmp_path, {
|
||||
"helper.php": (
|
||||
"<?php\nnamespace App;\nclass helper {\n"
|
||||
" public static function sanitize($x) { return $x; }\n}\n"),
|
||||
"Caller.php": (
|
||||
"<?php\nnamespace App;\nclass Caller {\n"
|
||||
" public function run($x) { return helper::sanitize($x); }\n}\n"),
|
||||
})
|
||||
edge = calls.get((".run()", ".sanitize()"))
|
||||
assert edge is not None
|
||||
assert edge["confidence"] == "EXTRACTED"
|
||||
|
||||
|
||||
def test_fully_qualified_scope_resolves_by_last_segment(tmp_path):
|
||||
"""A fully-qualified scope (`\\App\\Helper::method()` or
|
||||
`App\\Helper::method()`) must match the class's own unqualified label,
|
||||
not be left parked as unresolved (review finding on PR #3874)."""
|
||||
calls, _ = _extract(tmp_path, {
|
||||
"Helper.php": (
|
||||
"<?php\nnamespace App;\nclass Helper {\n"
|
||||
" public static function sanitize($x) { return $x; }\n}\n"),
|
||||
"Caller.php": (
|
||||
"<?php\nnamespace Other;\nclass Caller {\n"
|
||||
" public function run($x) { return \\App\\Helper::sanitize($x); }\n}\n"),
|
||||
})
|
||||
edge = calls.get((".run()", ".sanitize()"))
|
||||
assert edge is not None
|
||||
assert edge["confidence"] == "EXTRACTED"
|
||||
|
||||
|
||||
def test_method_name_case_insensitivity(tmp_path):
|
||||
"""PHP method names are case-insensitive at the call site - a call spelled
|
||||
with different case than the declaration must still resolve (review
|
||||
finding on PR #3874)."""
|
||||
calls, _ = _extract(tmp_path, {
|
||||
"Helper.php": (
|
||||
"<?php\nnamespace App;\nclass Helper {\n"
|
||||
" public static function Sanitize($x) { return $x; }\n}\n"),
|
||||
"Caller.php": (
|
||||
"<?php\nnamespace App;\nclass Caller {\n"
|
||||
" public function run($x) { return Helper::sanitize($x); }\n}\n"),
|
||||
})
|
||||
assert any(s == ".run()" and t.lower() == ".sanitize()" for s, t in calls)
|
||||
|
||||
|
||||
def test_instance_call_is_still_unresolved_no_regression(tmp_path):
|
||||
"""`$obj->method()` was never resolved before this fix (no receiver-typed
|
||||
PHP resolver existed) and must remain so - this fix only targets scoped
|
||||
|
||||
Reference in new issue
Block a user