-
-
Notifications
You must be signed in to change notification settings - Fork 11.8k
fix(php): resolve Class::method() static calls to the actual method #3874
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v8
Are you sure you want to change the base?
Changes from all commits
2ec4601
7ed0e1d
801c2a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4565,6 +4565,130 @@ def _bind_member_field_tables( | |
| return bound | ||
|
|
||
|
|
||
| def _resolve_php_member_calls( | ||
| per_file: list[dict], | ||
| all_nodes: list[dict], | ||
| all_edges: list[dict], | ||
| ) -> None: | ||
| 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 | ||
| bare function call named after the *scope* (``Helper::format()`` bound the | ||
| callee name to ``"Helper"``), so the cross-file bare-name pass silently | ||
| resolved every static call to whichever node happened to share the class's | ||
| label — almost always the class definition itself — instead of the actual | ||
| method, and did so with the generic import-evidence confidence gate even | ||
| though the receiver class is named explicitly in source. The engine now | ||
| captures the real method name as the callee and the scope text as the | ||
| receiver and marks it a member call; this resolver binds it to the class's | ||
| actual method node, mirroring ``_resolve_java_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. 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("()").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"} | ||
| node_by_id = {node.get("id"): node for node in all_nodes} | ||
|
|
||
| type_def_nids: dict[str, list[str]] = {} | ||
| for node in all_nodes: | ||
| if ( | ||
| node.get("source_file") | ||
| and node.get("id") in contained | ||
| and _is_type_like_definition(node) | ||
| ): | ||
| 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] = {} | ||
| for edge in all_edges: | ||
| if edge.get("relation") != "method": | ||
| continue | ||
| owner, method = edge.get("source"), edge.get("target") | ||
| method_node = node_by_id.get(method) | ||
| if method_node is None: | ||
| continue | ||
| enclosing_type.setdefault(method, owner) | ||
| method_index.setdefault((owner, key(method_node.get("label", ""))), set()).add(method) | ||
|
|
||
| inherits_bases: dict[str, list[str]] = {} | ||
| for edge in all_edges: | ||
| if edge.get("relation") == "inherits": | ||
| inherits_bases.setdefault(edge["source"], []).append(edge["target"]) | ||
|
|
||
| existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} | ||
| for result in per_file: | ||
| for raw_call in result.get("raw_calls", []): | ||
| if not raw_call.get("is_member_call"): | ||
| continue | ||
| if not str(raw_call.get("source_file", "")).endswith(".php"): | ||
| continue | ||
| receiver = raw_call.get("receiver") | ||
| callee = raw_call.get("callee") | ||
| caller = raw_call.get("caller_nid") | ||
| if not receiver or not callee or not caller: | ||
| continue | ||
|
|
||
| receiver_lower = receiver.lower() | ||
| if receiver_lower in ("self", "static"): | ||
| type_nid = enclosing_type.get(caller) | ||
| if not type_nid: | ||
| continue | ||
| elif receiver_lower == "parent": | ||
| bases = inherits_bases.get(enclosing_type.get(caller), []) | ||
| if len(bases) != 1: | ||
| continue | ||
| type_nid = bases[0] | ||
| 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, | ||
| ) | ||
| continue | ||
| if len(type_defs) != 1: | ||
| continue | ||
| type_nid = type_defs[0] | ||
| else: | ||
| # Dynamic scope ($var::method()) — no declared-type table to | ||
| # resolve against; leave unresolved rather than guess. | ||
| continue | ||
|
|
||
| method_nids = method_index.get((type_nid, key(callee)), set()) | ||
| if len(method_nids) != 1: | ||
| continue | ||
| method_nid = next(iter(method_nids)) | ||
| if method_nid == caller or (caller, method_nid) in existing_pairs: | ||
| continue | ||
| existing_pairs.add((caller, method_nid)) | ||
| all_edges.append({ | ||
| "source": caller, | ||
| "target": method_nid, | ||
| "relation": "calls", | ||
| "context": "call", | ||
| "confidence": "EXTRACTED", | ||
| "confidence_score": 1.0, | ||
| "source_file": raw_call.get("source_file", ""), | ||
| "source_location": raw_call.get("source_location"), | ||
| "weight": 1.0, | ||
| }) | ||
|
|
||
|
|
||
| def _resolve_java_member_calls( | ||
|
rikurunico marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
fans out to 6 callees (efferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. False positive — |
||
| per_file: list[dict], | ||
| all_nodes: list[dict], | ||
|
|
@@ -5504,6 +5628,9 @@ def _resolve_kotlin_member_calls( | |
| register_language_resolver( | ||
| LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls) | ||
| ) | ||
| register_language_resolver( | ||
| LanguageResolver("php_member_calls", frozenset({".php"}), _resolve_php_member_calls) | ||
| ) | ||
| register_language_resolver( | ||
| LanguageResolver("rust_self_member_calls", frozenset({".rs"}), _resolve_rust_self_member_calls) | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| """PHP `Class::method()` static calls must resolve to the actual method with | ||
| EXTRACTED confidence, not to a bare-name match on the class itself (#3872). | ||
|
|
||
| `scoped_call_expression` used the scope text (`Helper` in `Helper::format()`) | ||
| as the callee name instead of the method name, so the shared cross-file | ||
| bare-name pass bound every static call to whichever node happened to share | ||
| the class's label - almost always the class definition - and did so gated on | ||
| import evidence (INFERRED 0.85) even though the receiver class is named | ||
| explicitly in source. Same-namespace PHP classes never need a `use` import | ||
| for each other, so every same-namespace static call was stuck at INFERRED. | ||
| """ | ||
| 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"]} | ||
| call_edges = [e for e in r["edges"] if e["relation"] == "calls"] | ||
| calls = {(labels[e["source"]], labels[e["target"]]): e for e in call_edges} | ||
| return calls, r | ||
|
|
||
|
|
||
| def test_same_namespace_static_call_is_extracted_not_inferred(tmp_path): | ||
| """The exact shape from #3872: a same-namespace static call has no `use` | ||
| import (none needed), so it must not be penalized as INFERRED.""" | ||
| 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" | ||
| assert edge["confidence_score"] == 1.0 | ||
|
|
||
|
|
||
| def test_self_and_static_scope_resolve_to_enclosing_class(tmp_path): | ||
| """A same-named decoy method on an unrelated class proves `self::`/ | ||
| `static::` bind to the ENCLOSING class, not to any node sharing the bare | ||
| method name (review finding on PR #3874: without a decoy, a bare-name | ||
| coincidence would pass this test even if self/static were never scoped).""" | ||
| calls, _ = _extract(tmp_path, { | ||
| "Widget.php": ( | ||
| "<?php\nnamespace App;\nclass Widget {\n" | ||
| " public static function build() { return self::helper(); }\n" | ||
| " public static function render() { return static::helper(); }\n" | ||
| " public static function helper() { return 1; }\n}\n"), | ||
| "Decoy.php": ( | ||
| "<?php\nnamespace App;\nclass Decoy {\n" | ||
| " public static function helper() { return 2; }\n}\n"), | ||
| }) | ||
| assert (".build()", ".helper()") in calls | ||
| assert (".render()", ".helper()") in calls | ||
| assert calls[(".build()", ".helper()")]["confidence"] == "EXTRACTED" | ||
|
|
||
|
|
||
| def test_self_scope_does_not_resolve_to_other_class(tmp_path): | ||
| """`self::helper()` must NOT bind to another class's `helper()` even when | ||
| the enclosing class has no method of that name - `self::` names the | ||
| enclosing class, not a bare method-name match anywhere in the corpus.""" | ||
| calls, _ = _extract(tmp_path, { | ||
| "A.php": ( | ||
| "<?php\nnamespace App;\nclass A {\n" | ||
| " public function build() { return self::helper(); }\n}\n"), | ||
| "B.php": ( | ||
| "<?php\nnamespace App;\nclass B {\n" | ||
| " public function helper() { return 1; }\n}\n"), | ||
| }) | ||
| assert not any(s == ".build()" for s, _t in calls) | ||
|
|
||
|
|
||
| def test_parent_scope_resolves_through_inheritance(tmp_path): | ||
| calls, _ = _extract(tmp_path, { | ||
| "Base.php": ( | ||
| "<?php\nnamespace App;\nclass Base {\n" | ||
| " public static function helper() { return 1; }\n}\n"), | ||
| "Child.php": ( | ||
| "<?php\nnamespace App;\nclass Child extends Base {\n" | ||
| " public static function run() { return parent::helper(); }\n}\n"), | ||
| }) | ||
| assert (".run()", ".helper()") in calls | ||
|
|
||
|
|
||
| def test_dynamic_scope_produces_no_phantom_edge(tmp_path): | ||
| """`$cls::method()` has no declared-type table to resolve against - it | ||
| must stay unresolved rather than guess and mint a wrong edge. A same-file, | ||
| same-named decoy method proves the resolver actually ran and declined to | ||
| bind, rather than the guard never being exercised (review finding on PR | ||
| #3874: without the decoy, the old bare-name path would silently produce | ||
| the same "no edge to a DIFFERENT-labeled node" result for the wrong | ||
| reason).""" | ||
| calls, r = _extract(tmp_path, {"Caller.php": ( | ||
| "<?php\nnamespace App;\nclass Caller {\n" | ||
| " public function run($cls) { return $cls::helper(); }\n" | ||
| " public function helper() { return 1; }\n}\n")}) | ||
| 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 | ||
| (`::`) calls, not instance (`->`) calls.""" | ||
| calls, _ = _extract(tmp_path, { | ||
| "Helper.php": ( | ||
| "<?php\nnamespace App;\nclass Helper {\n" | ||
| " public function sanitize($x) { return $x; }\n}\n"), | ||
| "Caller.php": ( | ||
| "<?php\nnamespace App;\nclass Caller {\n" | ||
| " public function run(Helper $h, $x) { return $h->sanitize($x); }\n}\n"), | ||
| }) | ||
| assert not any(s == ".run()" for s, _t in calls) |
Uh oh!
There was an error while loading. Please reload this page.