diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 63a319e471..2d2876178c 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -42,6 +42,7 @@ "cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}), "csharp": frozenset({".cs"}), "java": frozenset({".java"}), + "php": frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}), "swift": frozenset({".swift"}), } diff --git a/graphify/extract.py b/graphify/extract.py index 7cc9e62c90..2c8ad0b3c0 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4414,6 +4414,98 @@ def _resolve_csharp_qualified_calls( }) +def _resolve_php_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve PHP member calls (``$greeter->greet()``) through the receiver's type. + + The shared cross-file pass skips member calls, so a call on a typed receiver whose + method is declared in another file resolved to nothing. The per-file + ``php_type_table`` names the declared type of every property, promoted constructor + parameter, typed parameter and ``new`` binding; this pass looks the receiver up + there, takes the single class declaring that type, and emits the ``calls`` edge to + its method. Always INFERRED: the type comes from the table, never from the call site + (``Helper::format()`` is a scoped call and keeps its own path). + + A receiver typed to a class this corpus declares nowhere is parked on the caller for + a merged graph to finish (#3152). + """ + raw = [ + rc + for result in per_file + for rc in result.get("raw_calls", []) + if rc.get("lang") == "php" and rc.get("is_member_call") + and rc.get("receiver") and rc.get("callee") and rc.get("caller_nid") + ] + if not raw: + return + type_table_by_file: dict[str, dict[str, str]] = {} + for result in per_file: + tt = result.get("php_type_table") + if tt and tt.get("path"): + type_table_by_file[tt["path"]] = tt.get("table", {}) + + def _key(label: str) -> str: + return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() + + # A genuine declaration is the target of a `contains` edge from its file node; a bare + # type reference mints a same-label stub that would otherwise make a real name ambiguous. + # Only PHP declarations count: the index is corpus-wide, so a same-named Java class + # would both answer the receiver and hide that nothing local declares its type. + contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + type_def_nids: dict[str, list[str]] = {} + node_by_id: dict[str, dict] = {} + for n in all_nodes: + node_by_id[n.get("id")] = n + if (_lang_family(n.get("source_file")) == "php" + and n.get("id") in contained and _is_type_like_definition(n)): + type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) + + method_index: dict[tuple[str, str], str] = {} + for e in all_edges: + if e.get("relation") != "method": + continue + tnode = node_by_id.get(e.get("target")) + if tnode is not None: + method_index[(e.get("source"), _key(tnode.get("label", "")))] = e["target"] + + php_builtins = _LANGUAGE_BUILTIN_BASE_CLASSES.get("php", frozenset()) + existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + for rc in raw: + receiver, callee, caller = rc["receiver"], rc["callee"], rc["caller_nid"] + type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) + if not type_name or type_name in _LANGUAGE_BUILTIN_GLOBALS or type_name in php_builtins: + continue + type_defs = type_def_nids.get(_key(type_name), []) + if not type_defs: + # Declared nowhere here — usually "in a repo this build does not contain", + # so park it for the merge (#3152). The extractor's `lang` tag already says + # who is asking, so no suffix sniff is needed. + _park_unresolved_member_call(node_by_id.get(caller), callee, type_name, "php", rc) + continue + if len(type_defs) != 1: # ambiguous -> bail (god-node guard) + continue + target = method_index.get((type_defs[0], _key(callee))) + if not target or target == caller or (caller, target) in existing_pairs: + continue + existing_pairs.add((caller, target)) + all_edges.append({ + "source": caller, + "target": target, + "relation": "calls", + "context": "call", + "confidence": "INFERRED", + # The rubric's discrete INFERRED scale (references/extraction-spec.md): + # a single-definition type-table hit is the high-confidence rung. + "confidence_score": 0.85, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + + def _resolve_kotlin_qualified_calls( per_file: list[dict], all_nodes: list[dict], @@ -4608,6 +4700,15 @@ def _resolve_kotlin_qualified_calls( "csharp_qualified_calls", frozenset({".cs"}), _resolve_csharp_qualified_calls ) ) +# PHP receiver-typed member-call resolution: `$greeter->greet()` where the method is +# declared in another file. The shared pass skips member calls, so these had no edge. +register_language_resolver( + LanguageResolver( + "php_member_calls", + frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}), + _resolve_php_member_calls, + ) +) # C# member-level interface dispatch (#3003): a call through an injected # dependency lands on the interface's method, so the implementation sits in the # graph unreachable from the call site. Lives in graphify.csharp_dispatch; diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index be6c38552c..c15fbecf84 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -664,6 +664,77 @@ def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[s if c.is_named: _php_collect_type_refs(c, source, generic, out) +def _php_declared_type_name(node, source: bytes) -> str | None: + """The single class name a PHP type annotation names, or None. + + Only `Greeter` and `?Greeter` qualify: a union, an intersection or a primitive names + no one class, and binding a receiver to the first arm of `A|B` would be a guess. + """ + if node is None: + return None + if node.type == "optional_type": + node = next((c for c in node.children if c.is_named), None) + if node is None: + return None + if node.type != "named_type": + return None + return next((_php_name_text(c, source) for c in node.children + if c.type in ("name", "qualified_name")), None) + + +def _php_variable_text(node, source: bytes) -> str | None: + """The bare name a PHP `variable_name` binds: `$greeter` -> `greeter`.""" + if node is None or node.type != "variable_name": + return None + return next((_read_text(c, source) for c in node.children if c.type == "name"), None) + + +def _php_first_declared_type(node, source: bytes) -> str | None: + """The first single-class type annotation among ``node``'s direct children.""" + return next((name for name in (_php_declared_type_name(c, source) for c in node.children) + if name), None) + + +def _php_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None: + """Collect ``name -> TypeName`` for every PHP receiver whose type is written down. + + Four sources, all needed: a typed property (`private Greeter $g;`), a constructor + promotion (`__construct(private Greeter $g)`), a typed parameter, and + `$g = new Greeter()`. File-scoped and flat, first binding wins — a parameter + shadowing a property in another method must not retype the property's own calls. + Children are pushed reversed so the walk yields document order and "first" means + first in the file. + """ + stack = [root] + while stack: + n = stack.pop() + t = n.type + if t == "property_declaration": + type_name = _php_first_declared_type(n, source) + for element in n.children if type_name else (): + if element.type != "property_element": + continue + name = next((_php_variable_text(c, source) for c in element.children + if c.type == "variable_name"), None) + if name and name not in table: + table[name] = type_name + elif t in ("property_promotion_parameter", "simple_parameter"): + type_name = _php_first_declared_type(n, source) + name = next((_php_variable_text(c, source) for c in n.children + if c.type == "variable_name"), None) + if name and type_name and name not in table: + table[name] = type_name + elif t == "assignment_expression": + right = n.child_by_field_name("right") + if right is not None and right.type == "object_creation_expression": + name = _php_variable_text(n.child_by_field_name("left"), source) + type_name = next((_php_name_text(c, source) for c in right.children + if c.type in ("name", "qualified_name")), None) + if name and type_name and name not in table: + table[name] = type_name + stack.extend(reversed(n.children)) + + def _php_method_return_type_node(method_node): """Return the named_type/primitive_type node sitting after formal_parameters.""" saw_params = False @@ -5477,6 +5548,17 @@ def walk_calls( name_node = node.child_by_field_name("name") if name_node: callee_name = _read_text(name_node, source) + # `$this->greeter->greet()` types the receiver by the property name, + # `$greeter->greet()` by the variable; a longer chain names neither. + obj = node.child_by_field_name("object") + if obj is not None and obj.type == "variable_name": + member_receiver = _php_variable_text(obj, source) + elif obj is not None and obj.type == "member_access_expression": + inner = obj.child_by_field_name("object") + if inner is not None and inner.type == "variable_name" and ( + _php_variable_text(inner, source) == "this"): + member_receiver = _read_text( + obj.child_by_field_name("name"), source) elif config.ts_module == "tree_sitter_cpp": # C++: function field, then field_expression/qualified_identifier func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None @@ -5636,9 +5718,14 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) + # PHP never defers: the receiver's type is only ever usable once the + # bare callee name misses in this file, which already leaves tgt_nid + # None and routes the call to raw_calls. + _php_keeps_in_file = config.ts_module == "tree_sitter_php" if _python_defer or _java_defer or ( is_member_call and member_receiver + and not _php_keeps_in_file and ( member_receiver[:1].isupper() or is_this_field_call @@ -5715,6 +5802,8 @@ def walk_calls( receiver_type = (receiver_types or {}).get(member_receiver or "") if receiver_type: rc_entry["receiver_type"] = receiver_type + if config.ts_module == "tree_sitter_php": + rc_entry["lang"] = "php" # Kotlin fully-qualified call (#2550): the dotted prefix + # lang tag let _resolve_kotlin_qualified_calls claim it. if kotlin_qualified_prefix: @@ -6150,6 +6239,8 @@ def _scan_js_module_dispatch(n) -> None: # a name clash (first-binding-wins in the helper). if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): _ts_receiver_type_table(root, source, type_table) + if config.ts_module == "tree_sitter_php": + _php_receiver_type_table(root, source, type_table) if config.ts_module == "tree_sitter_swift": if type_table or swift_factory_bindings: result["swift_type_table"] = {"path": str_path, "table": type_table} @@ -6163,6 +6254,8 @@ def _scan_js_module_dispatch(n) -> None: result["ts_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} + elif config.ts_module == "tree_sitter_php": + result["php_type_table"] = {"path": str_path, "table": type_table} return result def _python_decorator_name(deco_node, source: bytes) -> str | None: diff --git a/tests/test_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py index 3c16724646..5d36c2b4ea 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -6,8 +6,8 @@ `merge-graphs` and `global add` read. The two-repo graph was missing precisely the edges that make it a call graph. -The Java, C++, C# and Swift resolvers now park those calls on the caller node and -this pass finishes them after the merge. The cases below pin what it must NOT do +The Java, C++, C#, Swift and PHP resolvers now park those calls on the caller node +and this pass finishes them after the merge. The cases below pin what it must NOT do as much as what it must: the single-definition guard, the cross-repo-only scope, and the language guard are what keep it from fabricating an edge from a name collision. @@ -45,6 +45,7 @@ def _needs(module: str): needs_cpp = _needs("tree_sitter_cpp") needs_csharp = _needs("tree_sitter_c_sharp") needs_swift = _needs("tree_sitter_swift") +needs_php = _needs("tree_sitter_php") def _caller(repo: str, parked: list[dict], node_id: str = "app_run", @@ -373,6 +374,14 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path): ("src/Greeter.swift", "class Greeter { func greet() {} }\n"), marks=needs_swift, id="swift-property-receiver", ), + pytest.param( + "php", "greet", + ("src/App.php", "greeter->greet(); }\n}\n"), + ("src/Greeter.php", "greet()` on a receiver whose class lives in another file produced no edge — +the PHP twin of the Swift gap in #1356. Each case below pins one source of the +receiver's type, and the negative cases pin what must stay unresolved: an untyped +parameter, a union type, a longer chain, and an ambiguous class name. +""" +from __future__ import annotations + +import importlib + +import pytest + +from graphify.extract import extract + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("tree_sitter_php") is None, + reason="tree_sitter_php not installed", +) + +GREETER = " dict | None: + return next((e for (src, tgt), e in calls.items() + if src and "run" in src and tgt == ".greet()"), None) + + +def test_a_typed_property_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greeter->greet(); }\n}\n", + }) + edge = _greet_edge(calls) + assert edge is not None, calls + # The type came from the table, never from the call site: `$greeter` names a + # variable, so there is no spelling of this call that would be exact. + assert edge["confidence"] == "INFERRED" + + +def test_a_promoted_constructor_parameter_types_the_receiver(tmp_path): + # Constructor promotion declares no property, so nothing else in the walk ever + # names the receiver's type. + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greeter->greet(); }\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_typed_parameter_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greet(); }\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_new_binding_types_an_unannotated_local(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greet();\n }\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_nullable_property_type_still_names_one_class(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greeter->greet(); }\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_an_untyped_parameter_resolves_to_nothing(tmp_path): + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greet(); }\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_a_union_typed_receiver_resolves_to_nothing(tmp_path): + # `Greeter|Other` names no one class, and binding the first arm would be a guess. + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "Other.php": "greet(); }\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_a_longer_chain_resolves_to_nothing(tmp_path): + # `$this->a->b->greet()` types neither `a` nor `b` as the receiver. + calls, _ = _calls(tmp_path, { + "Greeter.php": GREETER, + "App.php": "greeter->inner->greet(); }\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_two_classes_of_the_same_name_resolve_to_neither(tmp_path): + # The single-definition guard: guessing one of two `Greeter`s is worse than + # leaving the call unresolved. + calls, _ = _calls(tmp_path, { + "a/Greeter.php": GREETER, + "b/Greeter.php": GREETER, + "App.php": "greeter->greet(); }\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_the_first_binding_of_a_name_wins(tmp_path): + # The table is flat per file, so a parameter named like a property has to lose: + # otherwise `other`'s signature would redirect the property's own calls. + calls, result = _calls(tmp_path, { + "Greeter.php": GREETER, + "Other.php": "greeter->greet(); }\n" + " public function other(Other $greeter): void { $greeter->greet(); }\n}\n", + }) + source_of = {n["id"]: str(n.get("source_file") or "") for n in result["nodes"]} + label_of = {n["id"]: n["label"] for n in result["nodes"]} + run_targets = {source_of[e["target"]] for e in result["edges"] + if e["relation"] == "calls" + and "run" in str(label_of.get(e["source"])) + and label_of.get(e["target"]) == ".greet()"} + assert len(run_targets) == 1, run_targets + assert run_targets.pop().endswith("Greeter.php") + + +def test_a_static_call_keeps_its_own_path(tmp_path): + # `Helper::format()` is a scoped call, not a member call, and still binds. + calls, _ = _calls(tmp_path, { + "Helper.php": "greeter->greet(); }\n}\n", + "Greeter.java": "public class Greeter { public void greet() {} }\n", + }) + assert _greet_edge(calls) is None, calls + parked = [(n.get("metadata") or {}).get("unresolved_calls") for n in result["nodes"] + if "run" in str(n["label"]) and n.get("metadata")] + assert parked == [[{"callee": "greet", "receiver_type": "Greeter", + "lang": "php", "line": "L4"}]], parked