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:
rikurunico committed 2026-09-27 11:59:31 +07:00
1 parent 2ec460168b
commit 7ed0e1d70a
2 files changed
+65 -10

No files matched your search

+16 -10
View File
@@ -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,
+49
View File
@@ -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