From 9c417ab5114792f688734caef2bd566fb7e2812b Mon Sep 17 00:00:00 2001 From: xiongjianxu <6457197+xiongjianxu@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:46:56 +0800 Subject: [PATCH 1/4] Resolve PHP member calls through the receiver's declared type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$greeter->greet()` produced no edge when `Greeter` was declared in another file: the shared cross-file pass skips member calls, and the PHP extractor never read `member_call_expression`'s object, so the receiver was not even recorded. Every call through an injected dependency was invisible to `affected` and to every reverse-dependency query. The extractor now captures the receiver — the variable for `$greeter->greet()`, the property name for `$this->greeter->greet()` — and exports a per-file `php_type_table` built from the four places a receiver's type is written down: a typed property, a promoted constructor parameter, a typed parameter, and `$g = new Greeter()`. `_resolve_php_member_calls` types the receiver from that table and emits `calls` to the single class declaring that type. Only a single class name binds: a union, an intersection or a primitive names no one class. In-file resolution is untouched — PHP does not defer to this pass, so a call whose bare callee name already matched in its own file resolves exactly as before, and the receiver type is consulted only after that miss. --- graphify/extract.py | 89 +++++++++++++ graphify/extractors/engine.py | 93 +++++++++++++ tests/test_php_receiver_member_calls.py | 165 ++++++++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 tests/test_php_receiver_member_calls.py diff --git a/graphify/extract.py b/graphify/extract.py index 99300f4303..13767c83bc 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4601,6 +4601,86 @@ 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). + """ + 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. + 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 n.get("source_file") 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 len(type_defs) != 1: # ambiguous or absent -> 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], @@ -4795,6 +4875,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 16109a7770..4785348bf6 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 @@ -5649,6 +5720,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 @@ -5823,9 +5905,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 _builtin_member_call or ( is_member_call and member_receiver + and not _php_keeps_in_file and ( member_receiver[:1].isupper() or is_this_field_call @@ -5921,6 +6008,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: @@ -6356,6 +6445,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} @@ -6369,6 +6460,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_php_receiver_member_calls.py b/tests/test_php_receiver_member_calls.py new file mode 100644 index 0000000000..6aa3716ad7 --- /dev/null +++ b/tests/test_php_receiver_member_calls.py @@ -0,0 +1,165 @@ +"""PHP member calls resolve through the receiver's declared type. + +The shared cross-file pass skips member calls, and PHP recorded no receiver at all, so +`$greeter->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": " Date: Mon, 7 Sep 2026 18:48:43 +0800 Subject: [PATCH 2/4] Park PHP member calls a merged graph can finish (#3152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PHP receiver typed to a class this build declares nowhere is a call into another repository, not a mistake. The resolver held the receiver type and dropped the call, so graph.json — the only artifact merge-graphs and global add read — recorded nothing and no merge-time pass could recover it. Those calls are now parked on the caller node by name, and the merge pass binds them when the type resolves to exactly one declaration in another repo. The suffix set covers every extension the PHP extractor claims, so a `.phtml` template that declares the class still answers. --- graphify/cross_repo_calls.py | 1 + graphify/extract.py | 11 ++++++++++- tests/test_cross_repo_member_calls.py | 13 +++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) 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 13767c83bc..5f9438613d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4615,6 +4615,9 @@ def _resolve_php_member_calls( 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 @@ -4660,7 +4663,13 @@ def _key(label: str) -> str: 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 len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard) + 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: 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", " Date: Mon, 7 Sep 2026 20:05:45 +0800 Subject: [PATCH 3/4] fix(php): index only PHP declarations for receiver typing A corpus-wide type index let a same-named class in another language answer a PHP receiver, and it hid from the parking branch that no PHP file declares the type. --- graphify/extract.py | 12 ++++++++++-- tests/test_php_receiver_member_calls.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 5f9438613d..3265aaaf8f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4601,6 +4601,11 @@ def _resolve_csharp_qualified_calls( }) +# Every suffix the PHP extractor claims. The member-call resolver both activates on and +# indexes declarations by this set, so an included `.phtml` class is not left out. +_PHP_SUFFIXES = (".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps") + + def _resolve_php_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -4639,12 +4644,15 @@ def _key(label: str) -> str: # 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 files 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 n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (str(n.get("source_file", "")).endswith(_PHP_SUFFIXES) + 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] = {} @@ -4889,7 +4897,7 @@ def _resolve_kotlin_qualified_calls( register_language_resolver( LanguageResolver( "php_member_calls", - frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}), + frozenset(_PHP_SUFFIXES), _resolve_php_member_calls, ) ) diff --git a/tests/test_php_receiver_member_calls.py b/tests/test_php_receiver_member_calls.py index 6aa3716ad7..a88f1d4588 100644 --- a/tests/test_php_receiver_member_calls.py +++ b/tests/test_php_receiver_member_calls.py @@ -163,3 +163,18 @@ def test_a_static_call_keeps_its_own_path(tmp_path): " public function run(): void { Helper::format(); }\n}\n", }) assert any(src and "run" in src and tgt == "Helper" for src, tgt in calls), calls + + +def test_a_class_from_another_language_never_answers_a_php_receiver(tmp_path): + # The declaration index is corpus-wide, so a same-named Java class would both answer + # the receiver and hide that no PHP file declares it — the call belongs to the merge. + calls, result = _calls(tmp_path, { + "App.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 From f17f5c9ac2e36c3ad0e6b86aff19d1977848985e Mon Sep 17 00:00:00 2001 From: xiongjianxu <6457197+xiongjianxu@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:11:17 +0800 Subject: [PATCH 4/4] refactor(php): gate the declaration index by interop family, as the shared resolver does --- graphify/extract.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 3265aaaf8f..2defb09909 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4601,11 +4601,6 @@ def _resolve_csharp_qualified_calls( }) -# Every suffix the PHP extractor claims. The member-call resolver both activates on and -# indexes declarations by this set, so an included `.phtml` class is not left out. -_PHP_SUFFIXES = (".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps") - - def _resolve_php_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -4644,14 +4639,14 @@ def _key(label: str) -> str: # 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 files 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. + # 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 (str(n.get("source_file", "")).endswith(_PHP_SUFFIXES) + 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"]) @@ -4897,7 +4892,7 @@ def _resolve_kotlin_qualified_calls( register_language_resolver( LanguageResolver( "php_member_calls", - frozenset(_PHP_SUFFIXES), + frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}), _resolve_php_member_calls, ) )