From 43cd7a2907eb015e539fb48c41c4528d51b62069 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 14:30:55 -0300 Subject: [PATCH 01/19] feat(php): resolve $this->prop->method() to the property's declared type (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP member calls were resolved by bare method name only, so a Laravel-style `$this->service->method()` either linked nothing or bound to whichever same-named method happened to be in the file. Cut the full path for the narrowest receiver family — `this` and `this.`. Extraction (engine.py): - capture the receiver of member/nullsafe member calls as `this` or `this.`; anything else stays uncaptured (behavior unchanged) - build a per-class table of concrete property types from typed properties and constructor-promoted params; unions, intersections, primitives and self/static/parent are refused, `?Foo` unwraps to Foo - stamp `lang: "php"` and the resolved `receiver_type` on raw calls - defer the in-file bare-name match only when a receiver type was actually stamped, so plain `$this->m()` and untyped receivers keep today's edges Resolution (extract.py): - `nullsafe_member_call_expression` joins the PHP call types - new `_resolve_php_member_calls`, a case-insensitive clone of the Java pass: exactly one type definition in the corpus and exactly one matching method, or no edge at all — never a bare-name fallback - registered as the `php_member_calls` language resolver Edges are INFERRED (0.8) for typed receivers and EXTRACTED (1.0) for `this`. Refs #2 Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 103 ++++++++- graphify/extractors/engine.py | 138 +++++++++++- tests/test_php_member_calls.py | 375 +++++++++++++++++++++++++++++++++ 3 files changed, 610 insertions(+), 6 deletions(-) create mode 100644 tests/test_php_member_calls.py diff --git a/graphify/extract.py b/graphify/extract.py index dc7540d5fa..1a30b8eb03 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -924,7 +924,10 @@ def _get_c_func_name(node, source: bytes) -> str | None: class_types=frozenset({"class_declaration"}), function_types=frozenset({"function_definition", "method_declaration"}), import_types=frozenset({"namespace_use_clause"}), - call_types=frozenset({"function_call_expression", "member_call_expression", "scoped_call_expression", "class_constant_access_expression"}), + # `$obj?->method()` parses as a distinct node type with the same + # object/name/arguments fields, so it flows through the member-call branch + # unchanged once it is recognized as a call at all (#1682). + call_types=frozenset({"function_call_expression", "member_call_expression", "nullsafe_member_call_expression", "scoped_call_expression", "class_constant_access_expression"}), static_prop_types=frozenset({"scoped_property_access_expression"}), helper_fn_names=frozenset({"config"}), container_bind_methods=frozenset({"bind", "singleton", "scoped", "instance"}), @@ -3025,6 +3028,95 @@ def key(label: str) -> str: }) +def _resolve_php_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve PHP member calls against the receiver's declared type (#1682). + + Receiver ``this`` binds to the caller's enclosing class (exact). Receiver + ``this.`` carries the type stamped by the extractor from the class's + typed properties and constructor-promoted params (inferred). A missing or + ambiguous receiver type is skipped rather than falling back to a bare + method-name match — a Laravel corpus has many identically named service + methods, and guessing between them is worse than an absent edge. + """ + def key(label: str) -> str: + # PHP class and method names are case-insensitive. + return str(label).strip().removeprefix(".").removesuffix("()").casefold() + + 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(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) + + 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 raw_call.get("lang") != "php" or not raw_call.get("is_member_call"): + 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 + + if receiver == "this": + exact = True + type_nid = enclosing_type.get(caller) + if not type_nid: + continue + else: + exact = False + type_name = raw_call.get("receiver_type") + if not type_name: + continue # untyped / union-typed / unknown receiver: refuse + type_defs = type_def_nids.get(key(type_name), []) + if len(type_defs) != 1: + continue # short name collides across the corpus: refuse + type_nid = type_defs[0] + + method_nids = method_index.get((type_nid, key(callee)), set()) + if len(method_nids) != 1: + continue # the typed receiver's class has no such method: refuse + 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" if exact else "INFERRED", + "confidence_score": 1.0 if exact else 0.8, + "source_file": raw_call.get("source_file", ""), + "source_location": raw_call.get("source_location"), + "weight": 1.0, + }) + + def _resolve_objc_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -3178,6 +3270,15 @@ def _key(label: str) -> str: register_language_resolver( LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls) ) +# PHP receiver-typed member-call resolution (#1682): `$this->prop->method()` +# bound to the property's declared type instead of a bare same-named match. +register_language_resolver( + LanguageResolver( + "php_member_calls", + frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}), + _resolve_php_member_calls, + ) +) # Pascal/Delphi cross-file inherited-method-call resolution: a call from a # manual descendant class to a method it inherits from an ancestor declared # in a DIFFERENT file (the common generated-base/manual-descendant split, diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index e16d1fc78b..bf47bb5643 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -609,6 +609,58 @@ 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) +# PHP type names that never denote a resolvable class definition. `self`, +# `static` and `parent` are relative (they need inheritance context the raw-call +# facts do not carry), the rest are builtins with no user definition. +_PHP_NON_CONCRETE_TYPE_NAMES = frozenset({ + "self", "static", "parent", "object", "mixed", "iterable", "callable", + "void", "never", "null", "true", "false", "array", "string", "int", + "float", "bool", +}) + + +def _php_concrete_type_name(type_node, source: bytes) -> str | None: + """Single concrete class name of a PHP type expression, or None (= refuse). + + Deliberately NOT `_php_collect_type_refs`: that helper flattens a union into + several refs, whereas a receiver typed `A|B` has no single type and must be + refused. `named_type` yields its namespace-stripped name; a nullable wrapper + around exactly one type unwraps (`?Foo` is still concretely Foo); union, + intersection, primitive and missing types yield None (#1682). + """ + if type_node is None: + return None + if type_node.type == "named_type": + for c in type_node.children: + if c.type in ("name", "qualified_name"): + text = _php_name_text(c, source) + if text and text.lower() not in _PHP_NON_CONCRETE_TYPE_NAMES: + return text + return None + return None + if type_node.type in ("optional_type", "nullable_type"): + inner = [c for c in type_node.named_children if c.type != "comment"] + if len(inner) == 1: + return _php_concrete_type_name(inner[0], source) + return None + + +def _php_method_receiver_types( + method_node, + source: bytes, + field_types: dict[str, str], +) -> dict[str, str]: + """Build the receiver type table visible to one PHP method (#1682). + + ``this.`` keys come from the declaring class's typed properties and + constructor-promoted params. PHP properties are reachable ONLY through + ``$this->``, so these keys can never collide with a local variable name. + Typed params and ``$var = new T()`` locals (bare keys) are a later slice — + the per-method signature is already in place for them. + """ + return {f"this.{name}": type_name for name, type_name in field_types.items()} + + def _php_method_return_type_node(method_node): """Return the named_type/primitive_type node sitting after formal_parameters.""" saw_params = False @@ -2376,6 +2428,10 @@ def _extract_generic( # same-named, explicitly typed receiver in a different method. csharp_field_types: dict[str, dict[str, str]] = {} csharp_method_scopes: dict[int, tuple[object, str]] = {} + # PHP receiver typing (#1682): typed properties and constructor-promoted + # params of the declaring class, keyed `this.` per method scope. + php_field_types: dict[str, dict[str, str]] = {} + php_method_scopes: dict[int, tuple[object, str]] = {} csharp_interface_names: set[str] = set() if config.ts_module == "tree_sitter_c_sharp": @@ -3209,6 +3265,18 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: "union_type", "intersection_type", "optional_type"): continue line = node.start_point[0] + 1 + # #1682: remember the property's declared type so a later + # `$this->prop->method()` resolves against it. Only a single + # concrete class name counts — unions/primitives are refused. + type_name = _php_concrete_type_name(c, source) + if type_name: + fields = php_field_types.setdefault(parent_class_nid, {}) + for pe in node.children: + if pe.type != "property_element": + continue + v = pe.child_by_field_name("name") + if v is not None: + fields[_read_text(v, source).lstrip("$")] = type_name refs: list[tuple[str, str]] = [] _php_collect_type_refs(c, source, False, refs) for ref_name, role in refs: @@ -3540,6 +3608,15 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: "union_type", "intersection_type", "optional_type"): type_node = sub break + # #1682: a promoted param IS a typed class property — + # record it in the same `this.` receiver table. + if is_promoted and parent_class_nid: + promoted_type = _php_concrete_type_name(type_node, source) + v = p.child_by_field_name("name") + if promoted_type and v is not None: + php_field_types.setdefault(parent_class_nid, {})[ + _read_text(v, source).lstrip("$") + ] = promoted_type refs: list[tuple[str, str]] = [] _php_collect_type_refs(type_node, source, False, refs) for ref_name, role in refs: @@ -3760,6 +3837,8 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: java_method_scopes[id(body)] = (node, parent_class_nid) if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid: csharp_method_scopes[id(body)] = (node, parent_class_nid) + if config.ts_module == "tree_sitter_php" and parent_class_nid: + php_method_scopes[id(body)] = (node, parent_class_nid) function_bodies.append((func_nid, body)) if config.ts_module == "tree_sitter_kotlin": # #2347: Kotlin anonymous objects (`object : Foo { … }`, @@ -3997,6 +4076,14 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: ) for body_id, (method_node, class_nid) in csharp_method_scopes.items() } + php_receiver_types = { + body_id: _php_method_receiver_types( + method_node, + source, + php_field_types.get(class_nid, {}), + ) + for body_id, (method_node, class_nid) in php_method_scopes.items() + } def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str, context: str) -> None: @@ -4310,11 +4397,33 @@ def walk_calls( if scope_node: callee_name = _read_text(scope_node, source) else: - # member_call_expression: $obj->method() + # member_call_expression / nullsafe_member_call_expression: + # $obj->method() / $obj?->method() is_member_call = True name_node = node.child_by_field_name("name") if name_node: callee_name = _read_text(name_node, source) + # #1682: capture the receiver so the cross-file PHP pass can + # bind the call to the receiver's DECLARED type. Gated on the + # node type because class_constant_access_expression lands in + # this else-branch too and has no `object`/`name` fields. + if node.type in ("member_call_expression", + "nullsafe_member_call_expression"): + obj = node.child_by_field_name("object") + if obj is not None and obj.type == "variable_name": + # $this->m() -> "this"; $svc->m() is a later slice. + var = _read_text(obj, source).lstrip("$") + if var == "this": + member_receiver = "this" + elif obj is not None and obj.type == "member_access_expression": + # $this->prop->m(): object=variable_name($this), + # name=name(prop). Deeper chains stay uncaptured. + inner = obj.child_by_field_name("object") + prop = obj.child_by_field_name("name") + if (inner is not None and inner.type == "variable_name" + and _read_text(inner, source) == "$this" + and prop is not None): + member_receiver = f"this.{_read_text(prop, 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 @@ -4444,7 +4553,17 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) - if _java_defer or ( + # PHP (#1682): defer ONLY when the receiver's type is actually + # known — a typed `$this->prop->m()` must not bare-match an + # unrelated same-named method in this file. Plain `$this->m()` + # and untyped receivers keep today's in-file match, since the + # resolver could add nothing for them anyway. + _php_receiver_type: str | None = None + if (config.ts_module == "tree_sitter_php" + and member_receiver and member_receiver != "this"): + _php_receiver_type = (receiver_types or {}).get(member_receiver) + _php_defer = bool(_php_receiver_type) + if _java_defer or _php_defer or ( is_member_call and member_receiver and ( @@ -4508,6 +4627,13 @@ def walk_calls( receiver_type = (receiver_types or {}).get(member_receiver or "") if receiver_type: rc_entry["receiver_type"] = receiver_type + # PHP: tag the raw_call so _resolve_php_member_calls claims + # it (and so other languages' resolvers can skip it), and + # stamp the receiver type resolved above (#1682). + if config.ts_module == "tree_sitter_php": + rc_entry["lang"] = "php" + if _php_receiver_type: + rc_entry["receiver_type"] = _php_receiver_type raw_calls.append(rc_entry) # Indirect dispatch: a function passed BY NAME as a call argument @@ -4743,9 +4869,11 @@ def walk_calls( # (#1630 Pattern B). Guarding on the tracked set prevents double-walking. _tracked_body_ids.update(id(b) for _, b in function_bodies) - # Body ids are unique (one language per file), so the Java and C# per-method - # receiver tables merge without collision. - receiver_types_by_body = {**java_receiver_types, **csharp_receiver_types} + # Body ids are unique (one language per file), so the Java, C# and PHP + # per-method receiver tables merge without collision. + receiver_types_by_body = { + **java_receiver_types, **csharp_receiver_types, **php_receiver_types, + } for caller_nid, body_node in function_bodies: walk_calls( body_node, diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py new file mode 100644 index 0000000000..497aca0904 --- /dev/null +++ b/tests/test_php_member_calls.py @@ -0,0 +1,375 @@ +"""PHP receiver-typed member-call resolution (#1682, tracer bullet). + +PHP ``member_call_expression`` nodes carry the receiver and the callee name, but +the extractor used to read only the bare name. A ``$this->prop->method()`` call +must select the method owned by the property's DECLARED type; receivers whose +type is untyped, union-typed or ambiguous stay unlinked rather than minting a +false call edge. + +Every test goes through the public ``extract()`` seam, and every positive case +carries a decoy class with an identically named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# Shared service + decoy: both define `search()`, so a bare method-name match +# cannot tell them apart — only the receiver's declared type can. +_SERVICE = "leadHunter->search(['status' => 'open']);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + assert (index, service_search) in calls + assert (index, decoy_search) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + assert edge["context"] == "call" + + +def test_typed_property_call_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_property_declared_after_the_caller_still_resolves(tmp_path: Path): + """The type table is complete before resolution — declaration order is free.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + " private LeadHunterService $leadHunter;\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_method_name_match_is_case_insensitive(tmp_path: Path): + """PHP method names are case-insensitive, so `SEARCH()` still binds.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->SEARCH([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_nullsafe_member_call_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter?->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert calls[(index, service_search)]["confidence"] == "INFERRED" + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_nullable_typed_property_unwraps_and_resolves(tmp_path: Path): + """`?Foo` is still concretely Foo — the nullable wrapper is unwrapped.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_untyped_property_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "an untyped receiver must not be guessed onto a same-named method" + + +def test_union_typed_property_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "a union-typed receiver has no single concrete type — refuse" + + +def test_self_typed_property_emits_no_edge(tmp_path: Path): + """`self`/`static`/`parent` are not concrete class names in the type table.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_duplicate_class_name_emits_no_edge(tmp_path: Path): + """Two `LeadHunterService` definitions: the single-definition guard refuses.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "legacy/Services/LeadHunterService.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "an ambiguous short class name must not resolve to either definition" + + +def test_unknown_method_has_no_fallback_edge(tmp_path: Path): + """The receiver's type is known but has no such method — refuse entirely.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->missingMethod();\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service = _find(r, "LeadHunterService", "app_services_leadhunterservice_leadhunterservice") + assert not any(src == index for src, _tgt in calls), \ + "an unknown method on a typed receiver must not fall back to any edge" + assert not any( + e.get("relation") == "references" + and e.get("source") == index + and e.get("target") == service + for e in r["edges"] + ), "no `references` consolation edge either — refuse, don't guess" + + +def test_this_self_call_still_extracted(tmp_path: Path): + """Plain `$this->method()` keeps today's same-file bare-name edge.""" + calls, r = _calls(tmp_path, { + "app/Http/ApiClient.php": ( + "fetch($path);\n" + " }\n" + " private function fetch(string $path): string { return $path; }\n" + "}\n" + ), + }) + + get = _find(r, ".get()", "apiclient") + fetch = _find(r, ".fetch()", "apiclient") + assert (get, fetch) in calls + + +def test_untyped_receiver_keeps_same_file_edge(tmp_path: Path): + """Deferral is gated on a stamped receiver type: an untyped receiver keeps + the in-file bare-name match it produced before this feature.""" + calls, r = _calls(tmp_path, { + "app/Http/ApiClient.php": ( + "helper->fetch($path);\n" + " }\n" + " private function fetch(string $path): string { return $path; }\n" + "}\n" + ), + }) + + get = _find(r, ".get()", "apiclient") + fetch = _find(r, ".fetch()", "apiclient") + assert (get, fetch) in calls + + +def test_static_call_edge_unchanged(tmp_path: Path): + """`Class::method()` still targets the CLASS node, as before this feature.""" + calls, r = _calls(tmp_path, { + "app/Context/SucursalContext.php": ( + " Date: Wed, 5 Aug 2026 14:46:47 -0300 Subject: [PATCH 02/19] feat(php): resolve (new Service())->method() with FQN corroboration (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inline instantiation names its class outright, so the receiver needs no type table — but the lookup that finds the class node still goes by SHORT name and ignores the namespace. Treating every inline new as exact would label a mis-bound short name EXTRACTED, so the namespace is checked as independent evidence. Extraction (engine.py): - capture `(new X())->m()` / `(new \NS\X())->m()` as the `(new)` receiver key, keeping the short name for lookup and the written text for corroboration - `new self()` / `new static()` / `new parent()` are refused by the same non-concrete type-name set as declared types - anonymous classes carry no name node at all (probe-verified on tree-sitter-php 0.24.1), so the receiver stays uncaptured and the call is inert; a bare `new X();` statement is still not a call node Resolution (extract.py): - new `_php_qualified_corroborates`: every segment of the written name must line up, case-insensitively, with the tail of the resolved node's path (PSR-4). A bare name corroborates nothing; a mismatching namespace downgrades to INFERRED rather than refusing, since the class name itself still resolved unambiguously Refs #3 Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 36 +++++++ graphify/extractors/engine.py | 37 ++++++- tests/test_php_member_calls.py | 180 +++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 3 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 1a30b8eb03..555afdba3c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3028,6 +3028,36 @@ def key(label: str) -> str: }) +def _php_qualified_corroborates(qualified: str | None, type_node: dict | None) -> bool: + """True when a source-written class name corroborates the resolved node (#1682). + + ``(new \\App\\Services\\Svc())`` names the class outright, but the short-name + lookup that found the node ignored the namespace — so the namespace is + independent evidence, and only a match makes the edge EXTRACTED. + + The check is deliberately narrow: PHP nodes carry no namespace, so the only + corroborating fact available here is the node's path, and PSR-4 maps + ``App\\Services\\Svc`` onto ``app/Services/Svc.php``. Every segment of the + written name must line up with the tail of that path, case-insensitively. + A BARE name (no namespace segment) corroborates nothing and stays INFERRED; + a namespace that does not line up downgrades rather than refusing, since the + class name itself still resolved unambiguously. + """ + if not qualified or not type_node: + return False + want = [seg.casefold() for seg in str(qualified).split("\\") if seg] + if len(want) < 2: + return False # bare `new Svc()`: no namespace written, no evidence + source_file = str(type_node.get("source_file") or "") + parts = [p for p in source_file.replace("\\", "/").split("/") + if p and p not in (".", "..")] + if not parts: + return False + parts[-1] = parts[-1].rsplit(".", 1)[0] # drop the file extension + parts = [p.casefold() for p in parts] + return len(parts) >= len(want) and parts[-len(want):] == want + + def _resolve_php_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -3096,6 +3126,12 @@ def key(label: str) -> str: if len(type_defs) != 1: continue # short name collides across the corpus: refuse type_nid = type_defs[0] + if receiver == "(new)": + # The class is named in the source; promote to EXTRACTED + # only when the written namespace backs the node we found. + exact = _php_qualified_corroborates( + raw_call.get("receiver_qualified"), node_by_id.get(type_nid) + ) method_nids = method_index.get((type_nid, key(callee)), set()) if len(method_nids) != 1: diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index bf47bb5643..a05868f553 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4276,6 +4276,11 @@ def walk_calls( is_this_field_call: bool = False swift_receiver: str | None = None member_receiver: str | None = None + # PHP inline instantiation `(new X())->m()` (#1682): the class is + # named in the source, so it needs no type table — keep both the + # short name (for lookup) and the written text (for corroboration). + php_inline_new_type: str | None = None + php_inline_new_qualified: str | None = None # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -4424,6 +4429,28 @@ def walk_calls( and _read_text(inner, source) == "$this" and prop is not None): member_receiver = f"this.{_read_text(prop, source)}" + elif obj is not None and obj.type == "parenthesized_expression": + # (new X())->m(): object_creation_expression is an + # UNFIELDED named child, and the class it names is + # an unfielded `name` (bare) or `qualified_name` + # (namespaced) child. An ANONYMOUS class parses as + # an `anonymous_class` child instead — it has no + # name node, so the scan finds nothing and the + # receiver stays uncaptured (verified by probe). + created = next((c for c in obj.named_children + if c.type == "object_creation_expression"), None) + cls = None + if created is not None: + cls = next((c for c in created.named_children + if c.type in ("name", "qualified_name")), None) + short = _php_name_text(cls, source) if cls is not None else None + # `new self()` / `new static()` / `new parent()` + # need inheritance context the raw-call facts do + # not carry — refused by the same non-concrete set. + if short and short.lower() not in _PHP_NON_CONCRETE_TYPE_NAMES: + member_receiver = "(new)" + php_inline_new_type = short + php_inline_new_qualified = _read_text(cls, 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 @@ -4559,9 +4586,11 @@ def walk_calls( # and untyped receivers keep today's in-file match, since the # resolver could add nothing for them anyway. _php_receiver_type: str | None = None - if (config.ts_module == "tree_sitter_php" - and member_receiver and member_receiver != "this"): - _php_receiver_type = (receiver_types or {}).get(member_receiver) + if config.ts_module == "tree_sitter_php": + if php_inline_new_type: + _php_receiver_type = php_inline_new_type + elif member_receiver and member_receiver != "this": + _php_receiver_type = (receiver_types or {}).get(member_receiver) _php_defer = bool(_php_receiver_type) if _java_defer or _php_defer or ( is_member_call @@ -4634,6 +4663,8 @@ def walk_calls( rc_entry["lang"] = "php" if _php_receiver_type: rc_entry["receiver_type"] = _php_receiver_type + if php_inline_new_qualified: + rc_entry["receiver_qualified"] = php_inline_new_qualified raw_calls.append(rc_entry) # Indirect dispatch: a function passed BY NAME as a call argument diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 497aca0904..9066ccc689 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -373,3 +373,183 @@ def test_static_call_edge_unchanged(tmp_path: Path): index = _find(r, ".index()", "leadcontroller") context_class = _find(r, "SucursalContext", "app_context_sucursalcontext_sucursalcontext") assert (index, context_class) in calls + + +# ── Inline instantiation receivers: `(new Service())->method()` (#3) ────────── +# +# The source names the class outright, so the receiver needs no type table. The +# edge is EXTRACTED only when the written qualified name CORROBORATES the +# resolved node (its namespace segments match the node's file path, PSR-4 +# style); a bare name carries no such evidence and stays INFERRED. + + +def _controller(body: str, uses: str = "") -> str: + return ( + "search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "EXTRACTED" + assert edge["confidence_score"] == 1.0 + + +def test_inline_new_bare_name_resolves_inferred(tmp_path: Path): + """A bare `new Service()` names no namespace — nothing corroborates it.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new LeadHunterService())->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + + +def test_inline_new_without_ctor_parens_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\App\\Services\\LeadHunterService)->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert calls[(index, service_search)]["confidence"] == "EXTRACTED" + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_inline_new_non_corroborating_namespace_downgrades(tmp_path: Path): + """`\\Legacy\\...\\LeadHunterService` resolves by short name to the only + definition in the corpus, but the written namespace does not match that + node's path — so the edge is emitted as INFERRED, not EXTRACTED.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\Legacy\\Services\\LeadHunterService())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + + +def test_inline_new_beats_same_file_same_named_method(tmp_path: Path): + """The named class wins over an identically named method in the caller's + own file — the bare-name match must not shadow an explicit `new`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + " public function search(array $filters): array { return []; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "leadcontroller")) not in calls + + +def test_inline_new_self_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new self())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "`new self()` needs inheritance context the raw-call facts lack — refuse" + + +def test_inline_new_static_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new static())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_anonymous_class_inline_new_emits_no_edge(tmp_path: Path): + """`new class { ... }` has no class name at all — nothing to resolve, and + no guess onto a same-named method elsewhere in the corpus.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new class { public function search(array $f): array " + "{ return []; } })->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_bare_new_statement_without_call_emits_no_edge(tmp_path: Path): + """`new Service();` on its own is not a call — still out of scope.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "new \\App\\Services\\LeadHunterService();\n return [];" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index for src, _tgt in calls) + + +def test_inline_new_unknown_method_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\App\\Services\\LeadHunterService())->missingMethod();" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index for src, _tgt in calls), \ + "the named class has no such method — refuse, don't fall back" From a707b42e52ffeed3b10e407d7ed70cfb7304ae7a Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 14:56:14 -0300 Subject: [PATCH 03/19] feat(php): resolve typed locals and typed params with scope poisoning (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$svc = new Service(); $svc->method()` and `function handle(Service $svc)` now carry a receiver type, so the call binds to the declared class instead of the first same-named method in the corpus. Raw calls retain no lexical scope, which makes shadowing the hard part: a call written inside a closure is attributed to the enclosing method, so a closure parameter reusing an outer name is indistinguishable from the outer binding. Rather than guess, `_php_method_receiver_types` POISONS any name whose binding is not provably single-typed and drops it from the table: - rebind to anything but a `new`, or two conflicting `new` types - augmented assignment (`$svc ??= new Other()`) - closure and arrow-function parameters shadowing the name - foreach targets, including `$k => &$v` and destructured elements - list destructuring, `[$a, [$b]] = …` and `list(…) = …` alike Anonymous-class bodies are skipped outright — a `new` inside one belongs to a different scope and must not type the enclosing method's variables. Variadic params are left unbound (`T ...$xs` is an array of T, not a T), and `self` / `static` in type position reuse the non-concrete name set. The bare `$var->m()` receiver key also required carving PHP out of the shared capitalized-receiver defer rule: PHP receivers are never bare class names, so that rule could only have stripped in-file edges off an untypable `$Svc->m()`. Chained receivers stay inert — `$a->b()->c()` resolves the inner call and leaves the outer one alone. Refs #4 Co-Authored-By: Claude Opus 5 --- graphify/extractors/engine.py | 138 ++++++++++++- tests/test_php_member_calls.py | 354 +++++++++++++++++++++++++++++++++ 2 files changed, 485 insertions(+), 7 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index a05868f553..89ca357847 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -645,6 +645,24 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: return None +# Subtrees that are a DIFFERENT binding scope than the method being scanned: +# their assignments must not type the enclosing method's variables. Closures are +# deliberately absent — their calls are attributed to the enclosing method, so +# their locals belong to the same raw-call scope (their PARAMETERS are poisoned +# separately, since a shadowed name cannot be told apart from the outer one). +_PHP_FOREIGN_SCOPE_TYPES = frozenset({ + "anonymous_class", + "class_declaration", + "interface_declaration", + "trait_declaration", + "enum_declaration", + "function_definition", + "method_declaration", +}) + +_PHP_CLOSURE_TYPES = frozenset({"anonymous_function", "arrow_function"}) + + def _php_method_receiver_types( method_node, source: bytes, @@ -655,10 +673,111 @@ def _php_method_receiver_types( ``this.`` keys come from the declaring class's typed properties and constructor-promoted params. PHP properties are reachable ONLY through ``$this->``, so these keys can never collide with a local variable name. - Typed params and ``$var = new T()`` locals (bare keys) are a later slice — - the per-method signature is already in place for them. + + Bare keys come from natively typed parameters and ``$var = new T()`` locals. + Raw calls retain no lexical scope, so a name is POISONED — dropped from the + table entirely — whenever its binding is not provably single-typed: a rebind + to anything but a `new`, two conflicting `new` types, an augmented + assignment, a closure or arrow-function parameter shadowing it, a foreach + target, or a list-destructuring element. Poisoning is order-independent, + which is why it can be decided from a single unordered walk. """ - return {f"this.{name}": type_name for name, type_name in field_types.items()} + table = {f"this.{name}": type_name for name, type_name in field_types.items()} + method_types: dict[str, str] = {} + ambiguous: set[str] = set() + + def poison(name: str) -> None: + if name: + method_types.pop(name, None) + ambiguous.add(name) + + def bind(name: str, type_name: str | None) -> None: + if not name or name in ambiguous: + return + previous = method_types.get(name) + if type_name is None or (previous is not None and previous != type_name): + poison(name) + else: + method_types[name] = type_name + + def poison_bound_vars(node) -> None: + """Poison every ``$var`` named anywhere in a binding-site subtree. + + Covers `[$a, [$b]] = …`, `list($a, $b) = …`, `foreach … as $k => &$v` + and closure parameter lists in one sweep (shapes probe-verified). + """ + stack = [node] + while stack: + n = stack.pop() + if n is None: + continue + if n.type == "variable_name": + poison(_read_text(n, source).lstrip("$")) + continue + stack.extend(n.children) + + def new_type_name(node) -> str | None: + """Class named by an ``object_creation_expression``, or None.""" + if node is None or node.type != "object_creation_expression": + return None + cls = next((c for c in node.named_children + if c.type in ("name", "qualified_name")), None) + if cls is None: # `new class { … }` names nothing + return None + text = _php_name_text(cls, source) + if not text or text.lower() in _PHP_NON_CONCRETE_TYPE_NAMES: + return None # `new self()` / `new static()` need inheritance context + return text + + # Natively typed parameters. `variadic_parameter` is excluded on purpose: + # `T ...$xs` binds an ARRAY of T, not a T. + params = method_node.child_by_field_name("parameters") + if params is not None: + for param in params.children: + if param.type not in ("simple_parameter", "property_promotion_parameter"): + continue + type_name = _php_concrete_type_name( + param.child_by_field_name("type"), source + ) + name_node = param.child_by_field_name("name") + if name_node is not None and type_name: + # Untyped / union / primitive params simply stay unbound. + bind(_read_text(name_node, source).lstrip("$"), type_name) + + body = method_node.child_by_field_name("body") + stack = list(body.children) if body is not None else [] + while stack: + node = stack.pop() + if node.type in _PHP_FOREIGN_SCOPE_TYPES: + continue + if node.type in _PHP_CLOSURE_TYPES: + poison_bound_vars(node.child_by_field_name("parameters")) + elif node.type == "foreach_statement": + # children are [iterated expression, target(s)…, body]; only the + # targets rebind names, and the element type is unknown. + body_node = node.child_by_field_name("body") + targets = [c for c in node.named_children if c is not body_node] + for target in targets[1:]: + poison_bound_vars(target) + elif node.type == "augmented_assignment_expression": + left = node.child_by_field_name("left") + if left is not None and left.type == "variable_name": + poison(_read_text(left, source).lstrip("$")) + elif node.type == "assignment_expression": + left = node.child_by_field_name("left") + if left is not None and left.type == "list_literal": + poison_bound_vars(left) + elif left is not None and left.type == "variable_name": + bind( + _read_text(left, source).lstrip("$"), + new_type_name(node.child_by_field_name("right")), + ) + stack.extend(node.children) + + table.update(method_types) + for name in ambiguous: + table.pop(name, None) + return table def _php_method_return_type_node(method_node): @@ -4416,10 +4535,10 @@ def walk_calls( "nullsafe_member_call_expression"): obj = node.child_by_field_name("object") if obj is not None and obj.type == "variable_name": - # $this->m() -> "this"; $svc->m() is a later slice. - var = _read_text(obj, source).lstrip("$") - if var == "this": - member_receiver = "this" + # $this->m() -> "this"; $svc->m() -> "svc", typed by + # the method-scoped table. `$this` is a reserved name + # in PHP, so the two key spaces cannot collide. + member_receiver = _read_text(obj, source).lstrip("$") elif obj is not None and obj.type == "member_access_expression": # $this->prop->m(): object=variable_name($this), # name=name(prop). Deeper chains stay uncaptured. @@ -4595,6 +4714,11 @@ def walk_calls( if _java_defer or _php_defer or ( is_member_call and member_receiver + # PHP's defer decision is fully expressed by _php_defer. Its + # receivers are variables, `this.` keys and `(new)` — + # never a bare class name — so the capitalized rule below + # would only strip in-file edges off an untypable `$Svc->m()`. + and config.ts_module != "tree_sitter_php" and ( member_receiver[:1].isupper() or is_this_field_call diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 9066ccc689..1a7585d739 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -553,3 +553,357 @@ def test_inline_new_unknown_method_emits_no_edge(tmp_path: Path): index = _find(r, ".index()", "leadcontroller") assert not any(src == index for src, _tgt in calls), \ "the named class has no such method — refuse, don't fall back" + + +# ── Typed locals and typed params, with scope poisoning (#4) ───────────────── +# +# A method-scoped receiver layer types `$var->m()` from `$var = new T()` locals +# and natively typed parameters. Raw calls carry no lexical scope, so any name +# whose binding is not provably single-typed is POISONED: a non-`new` rebind, a +# conflicting `new`, a closure/arrow-fn parameter, a foreach target, or a +# list-destructuring element. Anonymous-class bodies are a different scope +# entirely and bind nothing in the enclosing method. + + +def test_local_new_var_call_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + + +def test_local_new_qualified_var_call_resolves_inferred(tmp_path: Path): + """A local binding stays INFERRED even when the `new` is fully qualified — + FQN corroboration is scoped to the inline-new receiver form (#3).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert calls[(index, service_search)]["confidence"] == "INFERRED" + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_typed_param_receiver_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (handle, service_search) in calls + assert (handle, _find(r, ".search()", "auditlog")) not in calls + assert calls[(handle, service_search)]["confidence"] == "INFERRED" + + +def test_nullable_typed_param_receiver_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert (handle, _find(r, ".search()", "leadhunterservice")) in calls + assert (handle, _find(r, ".search()", "auditlog")) not in calls + + +def test_locals_resolve_per_method_independently(tmp_path: Path): + """The receiver layer is method-scoped: the same local name bound to two + different classes in two methods resolves to its own binding in each.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + " public function audit(): array {\n" + " $svc = new AuditLog();\n" + " return $svc->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + audit = _find(r, ".audit()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + assert (index, service_search) in calls + assert (index, decoy_search) not in calls + assert (audit, decoy_search) in calls + assert (audit, service_search) not in calls + + +def test_non_new_reassignment_poisons_local(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $svc = $other;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "a rebind to an untypable value poisons the name" + + +def test_conflicting_new_types_poison_local(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $svc = new AuditLog();\n" + " return $svc->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "two conflicting `new` types poison the name — no edge to EITHER class" + + +def test_augmented_assignment_poisons_local(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $svc ??= new AuditLog();\n" + " return $svc->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_closure_param_shadow_poisons_outer_name(tmp_path: Path): + """Calls inside a closure are attributed to the enclosing method, so a + closure parameter that shadows an outer name makes BOTH unresolvable.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $fn = function (AuditLog $svc) { return $svc->search([]); };\n" + " return $svc->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_arrow_fn_param_shadow_poisons_outer_name(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $fn = fn(AuditLog $svc) => $svc->search([]);\n" + " return $svc->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_foreach_target_shadow_poisons_outer_name(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " foreach ($rows as $svc) { $svc->search([]); }\n" + " return [];", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "a foreach target rebinds the name to an unknown element type" + + +def test_list_destructuring_poisons_outer_name(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " [$svc, $rest] = $pair;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_new_inside_anonymous_class_does_not_bind_enclosing_name(tmp_path: Path): + """An anonymous-class body is its own scope — its `new` must not type a + same-named variable in the method that contains the literal.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$anon = new class {\n" + " public function q(): void { $svc = new \\App\\Services\\LeadHunterService(); }\n" + " };\n" + " return $svc->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_chained_receiver_emits_no_edge(tmp_path: Path): + """`$a->b()->c()`: the outer receiver is a call result, not a typed name.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Report/Formatter.php": ( + "search([])->format([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls, \ + "the INNER call still resolves through the typed local" + assert (index, _find(r, ".format()", "formatter")) not in calls, \ + "the chained call's receiver has no known type" + + +def test_untyped_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) + + +def test_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) + + +def test_self_typed_param_emits_no_edge(tmp_path: Path): + """`self`/`static` parse as a plain `named_type` in parameter position, so + the non-concrete name set is what refuses them (probe-verified).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) + + +def test_variadic_typed_param_emits_no_edge(tmp_path: Path): + """`Service ...$svcs` binds an ARRAY of Service, not a Service.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) From 6d33de15e4fb97b40a96d421ea37365f3ee4419a Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 15:02:53 -0300 Subject: [PATCH 04/19] fix(extract): skip language-tagged raw calls in the Swift, Python and TypeScript member-call resolvers (#6) The Swift, Python and TypeScript resolvers walked every raw call in the corpus and claimed any entry with `is_member_call`, regardless of which language produced it. Since #2 stamped `lang: "php"` and a truthy receiver on PHP raw calls, a mixed PHP/Python corpus put foreign receiver data in front of three resolvers that had no way to tell it apart from their own. Add a `lang` tag skip at the top of each of the three loops. The extractor stamps `lang` for cpp, csharp, java and php (engine.py) and objc stamps its own (extractors/objc.py); Swift, Python and TypeScript raw calls carry no tag, so "tagged" is exactly "not mine". This also shuts the pre-existing path for objc-tagged raw calls, whose receivers ARE capitalized and so could reach the Python resolver's class arm. The Ruby resolver is deliberately untouched: ruby_resolution.py:47-48 already filters raw calls to `.rb`/`.rake` source files, so a `.php` entry cannot reach it. Tests: a mixed-corpus `extract()` test (.php + .py in one call) asserting a PHP receiver mints no edge into an identically named Python method, plus a positive control proving the skip did not simply disable the Python resolver. Scope note: with PHP's current receiver forms this guard is defensive rather than corrective. `engine.py:4410-4426` only ever emits `this` or `this.`, neither of which is capitalized, so no PHP raw call reaches the Python class arm today and both new tests pass with or without this change. The reachable cross-language leak found while verifying #6 has a different root cause -- the corpus-global, language-unscoped `type_def_nids` index inside _resolve_php_member_calls (extract.py:3068-3075) and its objc twin (extract.py:3211) -- and is left for a follow-up rather than widened into #2's resolver here. Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 15 +++ tests/test_mixed_corpus_member_calls.py | 119 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/test_mixed_corpus_member_calls.py diff --git a/graphify/extract.py b/graphify/extract.py index 1a30b8eb03..d227845fe1 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2315,6 +2315,11 @@ def _key(label: str) -> str: existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} for rc in all_raw_calls: + # A tagged raw_call belongs to the resolver that stamped it (cpp, csharp, + # java, php, objc). Swift raw_calls carry no `lang`, so anything tagged is + # another language's data and must not mint a Swift edge here (#1682). + if rc.get("lang"): + continue if not rc.get("is_member_call"): continue receiver = rc.get("receiver") @@ -2476,6 +2481,11 @@ def _emit_call(caller: str, target_nid: "str | None", rc: dict) -> None: }) for rc in all_raw_calls: + # A tagged raw_call belongs to the resolver that stamped it (cpp, csharp, + # java, php, objc). Python raw_calls carry no `lang`, so anything tagged is + # another language's data and must not mint a Python edge here (#1682). + if rc.get("lang"): + continue if not rc.get("is_member_call"): continue receiver = rc.get("receiver") @@ -2560,6 +2570,11 @@ def _key(label: str) -> str: existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} for rc in all_raw_calls: + # A tagged raw_call belongs to the resolver that stamped it (cpp, csharp, + # java, php, objc). TypeScript raw_calls carry no `lang`, so anything tagged + # is another language's data and must not mint a TS edge here (#1682). + if rc.get("lang"): + continue if not rc.get("is_member_call"): continue receiver = rc.get("receiver") diff --git a/tests/test_mixed_corpus_member_calls.py b/tests/test_mixed_corpus_member_calls.py new file mode 100644 index 0000000000..4634d499a3 --- /dev/null +++ b/tests/test_mixed_corpus_member_calls.py @@ -0,0 +1,119 @@ +"""Mixed-corpus isolation for the member-call resolvers (#6, spec #1682). + +A corpus that mixes PHP with another language must not let one language's raw +call data mint an edge through a different language's member-call resolver. +The extractor stamps ``lang`` on every cpp/csharp/java/php raw call (and objc +stamps its own), while Swift, Python and TypeScript raw calls carry no tag -- +so those three resolvers skip any tagged raw call outright. + +Every test goes through the public ``extract()`` seam, and the Python class +here doubles as the cross-language decoy: it owns an identically named method, +so a bare method-name match cannot tell it apart from the PHP target. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _nid(result: dict, label: str, file_suffix: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label + and str(node.get("source_file", "")).endswith(file_suffix) + ) + + +# A Python class whose method name collides with the PHP call's callee. Nothing +# in a PHP file may ever bind to it. +_PY_DECOY = ( + "class Lead:\n" + " def search(self, filters):\n" + " return []\n" +) + + +def test_php_capitalized_variable_receiver_yields_no_python_edge(tmp_path: Path): + """A capitalized PHP *variable* receiver must not reach the Python resolver. + + `$Lead->search()` spells a receiver that, read as a Python receiver, would + hit the Python resolver's capitalized-receiver class arm and bind to the + Python `Lead.search`. The PHP raw call is tagged `lang: "php"`, so the + Python resolver skips it. + """ + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "app/Runner.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + py_search = _nid(result, ".search()", "svc.py") + # No edge from anywhere in the PHP file may land on the Python method. + php_sourced = [ + (src, tgt) for (src, tgt) in calls + if str(calls[(src, tgt)].get("source_file", "")).endswith(".php") + ] + assert not [pair for pair in php_sourced if pair[1] == py_search], ( + "a PHP raw call minted an edge into the Python decoy method" + ) + + +def test_python_member_calls_still_resolve_in_a_mixed_corpus(tmp_path: Path): + """Positive control: the tag skip must not disable the Python resolver. + + Without this, the test above would pass even if the skip discarded every + raw call. A genuine Python capitalized-receiver call still resolves, and a + decoy class with the same method name gets no edge. + """ + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "decoy.py": ( + "class Audit:\n" + " def search(self, filters):\n" + " return []\n" + ), + "caller.py": ( + "from svc import Lead\n" + "\n" + "def run():\n" + " Lead.search({})\n" + ), + "app/Runner.php": ( + "search([]); }\n" + "}\n" + ), + }) + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, ".search()", "svc.py") + decoy_search = _nid(result, ".search()", "decoy.py") + assert (run, py_search) in calls, "genuine Python member call stopped resolving" + assert (run, decoy_search) not in calls, "decoy class received an edge" From 4f17dd8983aaada0840a575a20d8b4ef98b28faf Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 15:04:38 -0300 Subject: [PATCH 05/19] feat(php): refuse interface-typed receivers, even under short-name collision (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP `interface_declaration` is not in `_PHP_CONFIG.class_types`, so an interface mints no definition node. That looked safe — an interface-typed receiver simply found nothing — but it is not: Laravel's Contracts convention routinely puts `App\Contracts\Notifier` beside an unrelated `App\Support\Notifier` class, and then exactly ONE definition exists under that short name. The single-definition guard cannot see a problem, so the receiver silently bound to a total stranger. Measured before the fix: all three receiver entry points — typed property, typed parameter (#4) and inline new (#3) — minted the wrong edge in that corpus. Pre-scan interface names per file (the C# `_csharp_pre_scan_interfaces` pattern), thread them out on the extractor result, and refuse in the resolver any receiver type whose name matches one, case-insensitively. The check sits where the receiver type is first read, so every entry point is covered by construction. Implementations are never guessed, and the refusal is name-scoped: a class-typed receiver still resolves with an interface of another name in the corpus. Refs #5 Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 13 +++ graphify/extractors/engine.py | 35 +++++++ tests/test_php_member_calls.py | 181 +++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index 555afdba3c..6a885b3224 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3101,6 +3101,17 @@ def key(label: str) -> str: enclosing_type.setdefault(method, owner) method_index.setdefault((owner, key(method_node.get("label", ""))), set()).add(method) + # Names declared as `interface` anywhere in the corpus. PHP interfaces mint + # no definition node, so without this an interface-typed receiver would bind + # to whatever same-named CLASS happens to exist — the Laravel Contracts + # collision (`App\Contracts\Notifier` vs `App\Support\Notifier`), which the + # single-definition guard cannot see because there IS only one definition. + interface_names = { + key(name) + for result in per_file + for name in result.get("php_interfaces", []) + } + 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", []): @@ -3122,6 +3133,8 @@ def key(label: str) -> str: type_name = raw_call.get("receiver_type") if not type_name: continue # untyped / union-typed / unknown receiver: refuse + if key(type_name) in interface_names: + continue # a contract names no implementation: refuse type_defs = type_def_nids.get(key(type_name), []) if len(type_defs) != 1: continue # short name collides across the corpus: refuse diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 89ca357847..24155af897 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -143,6 +143,32 @@ def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: stack.extend(n.children) return out +def _php_pre_scan_interfaces(root_node, source: bytes) -> set[str]: + """Return names declared as `interface` in this PHP file (#1682). + + PHP interfaces are not in ``_PHP_CONFIG.class_types``, so they mint no + definition node and cannot be recognized by the resolver after the fact. + Laravel's Contracts convention makes the collision that follows realistic: + an `App\\Contracts\\Notifier` interface beside an unrelated + `App\\Support\\Notifier` class leaves exactly ONE definition under that + short name, which would satisfy the single-definition guard and bind an + interface-typed receiver to a total stranger. The names are threaded to the + resolver so it can refuse instead. + """ + out: set[str] = set() + stack = [root_node] + while stack: + n = stack.pop() + if n.type == "interface_declaration": + name_node = n.child_by_field_name("name") + if name_node is not None: + text = _read_text(name_node, source) + if text: + out.add(text) + stack.extend(n.children) + return out + + def _csharp_classify_base(name: str, interface_names: set[str]) -> str: """`implements` if the base name is an interface (declared or by I-prefix convention), else `inherits`.""" if name in interface_names: @@ -2556,6 +2582,10 @@ def _extract_generic( if config.ts_module == "tree_sitter_c_sharp": csharp_interface_names = _csharp_pre_scan_interfaces(root, source) + php_interface_names: set[str] = set() + if config.ts_module == "tree_sitter_php": + php_interface_names = _php_pre_scan_interfaces(root, source) + swift_protocol_names: set[str] = set() swift_class_names: set[str] = set() if config.ts_module == "tree_sitter_swift": @@ -5147,6 +5177,11 @@ def _scan_js_module_dispatch(n) -> None: n["_callable_class"] = True if swift_extensions: result["swift_extensions"] = swift_extensions + if php_interface_names: + # Interfaces mint no definition node, so the resolver cannot tell one + # from a same-named class without this (#1682). Sorted for a stable + # AST-cache payload. + result["php_interfaces"] = sorted(php_interface_names) # TS/JS: augment the constructor-injection type table with local `new` # bindings and type-annotated parameters, so `const s = new Svc(); s.m()` and # a call on a typed param (incl. inside a closure) resolve (#1630). The diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 1a7585d739..2ebe2f3ef3 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -907,3 +907,184 @@ def test_variadic_typed_param_emits_no_edge(tmp_path: Path): handle = _find(r, ".handle()", "leadcontroller") assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) + + +# ── Interface-typed receivers are refused (#5) ─────────────────────────────── +# +# PHP `interface_declaration` mints no definition node, so an interface-typed +# receiver normally resolves to nothing by accident. The dangerous case is +# Laravel's Contracts convention: `App\Contracts\Notifier` (interface) next to +# an unrelated `App\Support\Notifier` (class). The short-name lookup would find +# exactly one definition — the wrong one — and satisfy the ambiguity guard. +# Implementations are never guessed, and neither is a same-named stranger. + +_IFACE_CORPUS = { + "app/Contracts/Notifier.php": ( + " bool: + return any(src == caller and "notify" in tgt.lower() for src, tgt in calls) + + +def test_interface_typed_property_does_not_guess_implementation(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "mailnotifier")) not in calls, \ + "an interface names a contract, not an implementation — never guess" + assert not _notified(calls, go) + + +def test_interface_short_name_collision_emits_no_edge(tmp_path: Path): + """`App\\Contracts\\Notifier` (interface) and `App\\Support\\Notifier` + (unrelated class): exactly one DEFINITION exists, so the ambiguity guard + alone would happily bind the call to the stranger.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "support_notifier")) not in calls, \ + "the same-short-named class is not the interface the receiver declares" + assert not _notified(calls, go) + + +def test_interface_refusal_is_case_insensitive(tmp_path: Path): + """PHP type names are case-insensitive: `notifier` IS `Notifier`.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert not _notified(calls, go) + + +def test_interface_typed_param_emits_no_edge(tmp_path: Path): + """The typed-parameter receiver path (#4) refuses interfaces too.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "support_notifier")) not in calls + assert not _notified(calls, go) + + +def test_interface_inline_new_emits_no_edge(tmp_path: Path): + """The inline-new receiver path (#3) refuses interfaces too — an interface + cannot be instantiated, so such a receiver must never bind a stranger.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notify('x');\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "support_notifier")) not in calls + assert not _notified(calls, go) + + +def test_interface_typed_local_new_emits_no_edge(tmp_path: Path): + """The typed-local receiver path (#4) refuses interfaces too.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notify('x');\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "support_notifier")) not in calls + assert not _notified(calls, go) + + +def test_class_receiver_still_resolves_when_an_interface_exists(tmp_path: Path): + """The refusal is name-scoped: a CLASS-typed receiver still resolves, and + the same-named interface elsewhere in the corpus changes nothing.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Audit/AuditTrail.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "mailnotifier")) in calls + assert (go, _find(r, ".notify()", "audittrail")) not in calls + assert (go, _find(r, ".notify()", "support_notifier")) not in calls From 2e68ec6e5b1406fe3bf6aaee91580df0db0baf7f Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 15:14:11 -0300 Subject: [PATCH 06/19] fix(extract): language-scope the PHP and ObjC receiver type indexes (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lang`-tagging (#6) keeps one language's raw calls out of another language's resolver, but the DEFINITION index each resolver builds was assembled from every type-like node in the corpus. A PHP receiver type name was therefore matched against classes written in any language, and that cut both ways: - a Python `class Lead` could be bound as the PHP receiver's type, minting a cross-language INFERRED edge from PHP into Python - worse, a Python class merely SHARING the name pushed the single-definition guard to two candidates, so the correct PHP-to-PHP edge was silently suppressed — any polyglot repo with a colliding class name lost PHP member-call resolution entirely Scope both indexes by the resolver's own registered source suffixes. The suffix tuples now have one definition each and feed both the registration and the index, so the two cannot drift apart. `_resolve_objc_member_calls` carries the identical defect (pre-existing, not introduced by the PHP work) and gets the same fix here. Its `.h` dual-routing is unaffected: raw calls are still claimed by the extractor-stamped `lang`, and `.h` belongs in the ObjC definition scope because an @interface lives in one. Refs #8 Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 30 ++++++- tests/test_mixed_corpus_member_calls.py | 104 ++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 94d9887a4c..390ec789d4 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3043,6 +3043,18 @@ def key(label: str) -> str: }) +# Source suffixes each resolver owns. Used BOTH to register the resolver and to +# scope its receiver-type index (#8) — one definition so the two cannot drift. +_PHP_RESOLVER_SUFFIXES = ( + ".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", +) +# `.h` routes to extract_cpp or extract_objc by content, so it appears in both +# the C++ and ObjC sets. Raw calls are still claimed by the extractor-stamped +# `lang`; only the DEFINITION index is scoped by suffix, where including `.h` is +# correct — an ObjC @interface lives in one. +_OBJC_RESOLVER_SUFFIXES = (".m", ".mm", ".h") + + def _php_qualified_corroborates(qualified: str | None, type_node: dict | None) -> bool: """True when a source-written class name corroborates the resolved node (#1682). @@ -3095,10 +3107,15 @@ def key(label: str) -> str: if edge.get("relation") == "contains"} node_by_id = {node.get("id"): node for node in all_nodes} + # Scoped to PHP sources (#8). An unscoped index matched a PHP receiver type + # against classes written in ANY language, which cut both ways: a Python + # `class Lead` could be bound as the receiver's type, and a Python class + # merely SHARING the name pushed the single-definition guard to 2 and + # silently suppressed the correct PHP edge. type_def_nids: dict[str, list[str]] = {} for node in all_nodes: if ( - node.get("source_file") + str(node.get("source_file") or "").lower().endswith(_PHP_RESOLVER_SUFFIXES) and node.get("id") in contained and _is_type_like_definition(node) ): @@ -3216,11 +3233,16 @@ def _key(label: str) -> str: contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Scoped to ObjC sources (#8), same defect and fix as the PHP twin: an + # unscoped index let a foreign class type an ObjC receiver, and let a + # foreign class merely sharing the name suppress the correct ObjC edge. 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): + sf = str(n.get("source_file") or "").lower() + if (sf.endswith(_OBJC_RESOLVER_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] = {} @@ -3322,7 +3344,7 @@ def _key(label: str) -> str: register_language_resolver( LanguageResolver( "objc_member_calls", - frozenset({".m", ".mm", ".h"}), + frozenset(_OBJC_RESOLVER_SUFFIXES), _resolve_objc_member_calls, ) ) @@ -3339,7 +3361,7 @@ def _key(label: str) -> str: register_language_resolver( LanguageResolver( "php_member_calls", - frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}), + frozenset(_PHP_RESOLVER_SUFFIXES), _resolve_php_member_calls, ) ) diff --git a/tests/test_mixed_corpus_member_calls.py b/tests/test_mixed_corpus_member_calls.py index 4634d499a3..3fb09f3084 100644 --- a/tests/test_mixed_corpus_member_calls.py +++ b/tests/test_mixed_corpus_member_calls.py @@ -117,3 +117,107 @@ def test_python_member_calls_still_resolve_in_a_mixed_corpus(tmp_path: Path): decoy_search = _nid(result, ".search()", "decoy.py") assert (run, py_search) in calls, "genuine Python member call stopped resolving" assert (run, decoy_search) not in calls, "decoy class received an edge" + + +# ── Language-scoped receiver type index (#8) ───────────────────────────────── +# +# The `lang` tag above keeps one language's raw calls out of another +# language's resolver. It does NOT scope the DEFINITION index each resolver +# builds: `type_def_nids` was assembled from every type-like node in the +# corpus, so a receiver type name was matched against classes written in any +# language. That cut both ways — a foreign class could be bound as the +# receiver's type, and a foreign class sharing the name could trip the +# single-definition guard and suppress the correct same-language edge. + + +def test_php_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """Defect 1: no PHP `class Lead` exists, only a Python one — refuse.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "app/Runner.php": ( + "lead->search([]); }\n" + "}\n" + ), + }) + + go = _nid(result, ".go()", "Runner.php") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "a PHP receiver type must not resolve against a Python class" + + +def test_php_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """Defect 2 (the damaging one): a cross-language name collision must not + make the god-node guard suppress the legitimate PHP-to-PHP edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "app/Lead.php": ( + "lead->search([]); }\n" + "}\n" + ), + }) + + go = _nid(result, ".go()", "Runner.php") + php_search = _nid(result, ".search()", "Lead.php") + py_search = _nid(result, ".search()", "svc.py") + assert (go, php_search) in calls, \ + "a same-named class in another language suppressed the real PHP edge" + assert (go, py_search) not in calls + assert calls[(go, php_search)]["confidence"] == "INFERRED" + + +def test_objc_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """Defect 1, ObjC twin: `[Lead search]` with no ObjC `Lead` in the corpus.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "src/Runner.m": ( + "@implementation Runner\n" + "- (void)go { [Lead search]; }\n" + "@end\n" + ), + }) + + go = _nid(result, "-go", "Runner.m") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "an ObjC receiver type must not resolve against a Python class" + + +def test_objc_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """Defect 2, ObjC twin: the collision must not suppress the ObjC edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "src/Lead.h": "@interface Lead : NSObject\n- (void)search;\n@end\n", + "src/Lead.m": ( + '#import "Lead.h"\n@implementation Lead\n- (void)search {}\n@end\n' + ), + "src/Runner.m": ( + '#import "Lead.h"\n@implementation Runner\n' + "- (void)go { [Lead search]; }\n@end\n" + ), + }) + + go = _nid(result, "-go", "Runner.m") + py_search = _nid(result, ".search()", "svc.py") + objc_search = next( + node["id"] for node in result["nodes"] + if node.get("label") == "-search" + and str(node.get("source_file", "")).endswith((".h", ".m")) + ) + assert (go, objc_search) in calls, \ + "a same-named Python class suppressed the real ObjC edge" + assert (go, py_search) not in calls From 5de8f766aee85b9b127e36d5dbe57028a1e63f4e Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 15:46:49 -0300 Subject: [PATCH 07/19] feat(php): refuse enum- and trait-typed receivers alongside interfaces (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enum_declaration` and `trait_declaration` are absent from `_PHP_CONFIG.class_types` just like `interface_declaration`, so they mint no definition node — and the #5 pre-scan only collected interfaces. An enum-typed receiver was therefore invisible to BOTH the resolver and the refusal set, so an unrelated class merely sharing its short name became the single visible definition and sailed through the god-node guard: `App\Enums\Status` (enum) beside `App\Legacy\Status` (class) bound `$this->status->label()` to the stranger at INFERRED 0.8. All four receiver entry points leaked, including the FQN-written one, where the source names the enum unambiguously. Generalize the pre-scan to every PHP declaration kind that mints no node — interface, enum, trait — and refuse those receiver types. Refusal side only: enums and traits still mint no definition nodes, so nothing else about extraction changes. That leaves the recall gap named in #12 (an enum's own methods are not resolvable call targets) deliberately open; minting nodes for these declarations is a separate decision. The resolver still reads the pre-#12 `php_interfaces` result key so an AST cache entry written before this change keeps refusing interfaces. --- graphify/extract.py | 26 ++-- graphify/extractors/engine.py | 45 ++++--- tests/test_php_member_calls.py | 217 +++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 26 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 390ec789d4..fa7f770c96 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3133,15 +3133,20 @@ def key(label: str) -> str: enclosing_type.setdefault(method, owner) method_index.setdefault((owner, key(method_node.get("label", ""))), set()).add(method) - # Names declared as `interface` anywhere in the corpus. PHP interfaces mint - # no definition node, so without this an interface-typed receiver would bind - # to whatever same-named CLASS happens to exist — the Laravel Contracts - # collision (`App\Contracts\Notifier` vs `App\Support\Notifier`), which the - # single-definition guard cannot see because there IS only one definition. - interface_names = { + # Names declared as `interface`, `enum` or `trait` anywhere in the corpus. + # None of the three mints a definition node, so without this such a receiver + # would bind to whatever same-named CLASS happens to exist — the Laravel + # Contracts collision (`App\Contracts\Notifier` vs `App\Support\Notifier`) + # or the enum-beside-model one (`App\Enums\Status` vs `App\Models\Status`), + # neither of which the single-definition guard can see because there IS only + # one definition. `php_interfaces` is the pre-#12 spelling of the same fact, + # still read so an AST-cache entry written before enums and traits joined + # the set keeps refusing interfaces. + non_class_type_names = { key(name) for result in per_file - for name in result.get("php_interfaces", []) + for keyname in ("php_non_class_types", "php_interfaces") + for name in result.get(keyname, []) } existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} @@ -3165,8 +3170,11 @@ def key(label: str) -> str: type_name = raw_call.get("receiver_type") if not type_name: continue # untyped / union-typed / unknown receiver: refuse - if key(type_name) in interface_names: - continue # a contract names no implementation: refuse + if key(type_name) in non_class_type_names: + # An interface names no implementation, a trait is not a + # type, and an enum's methods live on no definition node: + # refuse rather than bind a same-short-named stranger. + continue type_defs = type_def_nids.get(key(type_name), []) if len(type_defs) != 1: continue # short name collides across the corpus: refuse diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 24155af897..231c080761 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -143,23 +143,34 @@ def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: stack.extend(n.children) return out -def _php_pre_scan_interfaces(root_node, source: bytes) -> set[str]: - """Return names declared as `interface` in this PHP file (#1682). +# PHP declaration kinds that are NOT in `_PHP_CONFIG.class_types`: they mint no +# definition node, so the resolver cannot recognize them after the fact (#1682). +_PHP_NON_CLASS_DECLARATIONS = frozenset({ + "interface_declaration", + "enum_declaration", + "trait_declaration", +}) - PHP interfaces are not in ``_PHP_CONFIG.class_types``, so they mint no + +def _php_pre_scan_non_class_declarations(root_node, source: bytes) -> set[str]: + """Return names declared as `interface`, `enum` or `trait` in this PHP file (#1682). + + None of the three is in ``_PHP_CONFIG.class_types``, so they mint no definition node and cannot be recognized by the resolver after the fact. - Laravel's Contracts convention makes the collision that follows realistic: - an `App\\Contracts\\Notifier` interface beside an unrelated - `App\\Support\\Notifier` class leaves exactly ONE definition under that - short name, which would satisfy the single-definition guard and bind an - interface-typed receiver to a total stranger. The names are threaded to the - resolver so it can refuse instead. + Laravel's conventions make the collision that follows realistic: an + `App\\Contracts\\Notifier` interface beside an unrelated + `App\\Support\\Notifier` class — or an `App\\Enums\\Status` enum beside an + Eloquent `App\\Models\\Status` — leaves exactly ONE definition under that + short name, which would satisfy the single-definition guard and bind the + receiver to a total stranger. The names are threaded to the resolver so it + can refuse instead. Refusal only: minting nodes for these declarations + would change what the graph contains, which is a separate decision. """ out: set[str] = set() stack = [root_node] while stack: n = stack.pop() - if n.type == "interface_declaration": + if n.type in _PHP_NON_CLASS_DECLARATIONS: name_node = n.child_by_field_name("name") if name_node is not None: text = _read_text(name_node, source) @@ -2582,9 +2593,9 @@ def _extract_generic( if config.ts_module == "tree_sitter_c_sharp": csharp_interface_names = _csharp_pre_scan_interfaces(root, source) - php_interface_names: set[str] = set() + php_non_class_type_names: set[str] = set() if config.ts_module == "tree_sitter_php": - php_interface_names = _php_pre_scan_interfaces(root, source) + php_non_class_type_names = _php_pre_scan_non_class_declarations(root, source) swift_protocol_names: set[str] = set() swift_class_names: set[str] = set() @@ -5177,11 +5188,11 @@ def _scan_js_module_dispatch(n) -> None: n["_callable_class"] = True if swift_extensions: result["swift_extensions"] = swift_extensions - if php_interface_names: - # Interfaces mint no definition node, so the resolver cannot tell one - # from a same-named class without this (#1682). Sorted for a stable - # AST-cache payload. - result["php_interfaces"] = sorted(php_interface_names) + if php_non_class_type_names: + # Interfaces, enums and traits mint no definition node, so the resolver + # cannot tell one from a same-named class without this (#1682). Sorted + # for a stable AST-cache payload. + result["php_non_class_types"] = sorted(php_non_class_type_names) # TS/JS: augment the constructor-injection type table with local `new` # bindings and type-annotated parameters, so `const s = new Svc(); s.m()` and # a call on a typed param (incl. inside a closure) resolve (#1630). The diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 2ebe2f3ef3..f8bc0113e8 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -1088,3 +1088,220 @@ def test_class_receiver_still_resolves_when_an_interface_exists(tmp_path: Path): assert (go, _find(r, ".notify()", "mailnotifier")) in calls assert (go, _find(r, ".notify()", "audittrail")) not in calls assert (go, _find(r, ".notify()", "support_notifier")) not in calls + + +# ── Enum- and trait-typed receivers are refused (#12) ──────────────────────── +# +# `enum_declaration` and `trait_declaration` mint no definition node either, so +# they leak exactly like interfaces did before #5: `App\Enums\Status` (enum) +# beside an unrelated `App\Legacy\Status` (class) leaves ONE definition under +# that short name, and the single-definition guard binds the stranger. The +# Laravel shape is an enum mirroring a model. Enums and traits are added to the +# refusal pre-scan only — they still mint no nodes, so an enum's own methods +# stay unresolvable as call targets (a deliberate recall gap, not a wrong edge). + +_ENUM_CORPUS = { + "app/Enums/Status.php": ( + " bool: + return any(src == caller and "label" in tgt.lower() for src, tgt in calls) + + +def _runner(body: str) -> str: + return ( + "status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert not _labelled(calls, go) + + +def test_enum_promoted_ctor_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " public function __construct(private Status $status) {}\n" + " public function go(): void { $this->status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert not _labelled(calls, go) + + +def test_enum_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " public function go(Status $s): void { $s->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert not _labelled(calls, go) + + +def test_enum_fqn_typed_property_emits_no_edge(tmp_path: Path): + """The sharpest form: the source names `\\App\\Enums\\Status` outright, so + binding `App\\Legacy\\Status` contradicts the written type.""" + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " private \\App\\Enums\\Status $status;\n" + " public function go(): void { $this->status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert not _labelled(calls, go) + + +def test_enum_typed_local_new_emits_no_edge(tmp_path: Path): + """The typed-local receiver path (#4) refuses enums too.""" + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " public function go(): void {\n" + " $s = new Status();\n" + " $s->label();\n" + " }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert not _labelled(calls, go) + + +def test_enum_inline_new_emits_no_edge(tmp_path: Path): + """The inline-new receiver path (#3) refuses enums too — an enum cannot be + instantiated, so such a receiver must never bind a stranger.""" + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " public function go(): void {\n" + " (new \\App\\Enums\\Status())->label();\n" + " }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert not _labelled(calls, go) + + +def test_enum_refusal_is_case_insensitive(tmp_path: Path): + """PHP type names are case-insensitive: `status` IS `Status`.""" + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " private status $status;\n" + " public function go(): void { $this->status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert not _labelled(calls, go) + + +def test_enum_without_a_colliding_class_emits_no_edge(tmp_path: Path): + """Control: an enum mints no definition node, so its methods are not call + targets at all. The collision above supplies the only candidate — this + documents the (deliberate) recall gap that leaves.""" + calls, r = _calls(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + "app/Runner.php": _runner( + " private Status $status;\n" + " public function go(): void { $this->status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert not _labelled(calls, go) + + +def test_trait_typed_receiver_emits_no_edge(tmp_path: Path): + """A trait is not a type, so a trait-typed receiver is already broken PHP — + but it must still refuse rather than bind the same-short-named class.""" + calls, r = _calls(tmp_path, { + "app/Support/Cache.php": ( + "cache->flush(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".flush()", "legacy_cache")) not in calls + assert not any(src == go and "flush" in tgt.lower() for src, tgt in calls) + + +def test_class_receiver_still_resolves_when_an_enum_exists(tmp_path: Path): + """The refusal is name-scoped: a CLASS-typed receiver still resolves with an + unrelated enum (and a same-named-method decoy class) in the corpus.""" + calls, r = _calls(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + "app/Models/Lead.php": ( + "lead->label(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "lead")) in calls + assert (go, _find(r, ".label()", "audittrail")) not in calls From 0cb78af1fb4c131fd569d13d044870232abf41c6 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 15:50:20 -0300 Subject: [PATCH 08/19] fix(php): poison receiver types rebound by `global` and `static` statements (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4's scope poisoning covered every way a local can be REASSIGNED, but not the two statements that rebind a name to DIFFERENT STORAGE. `$svc = new Alpha(); global $svc;` leaves the name aliased to the global slot and `static $svc;` rebinds it to the function-static slot (initially null), yet the table kept the `Alpha` binding and minted an INFERRED 0.8 edge to a method the receiver can never reach at runtime. Both idioms are native to the pre-PSR-4 codebases this feature targets: `global $db;` and `static $conn;` memoization. Poison every name a `global_declaration` or `function_static_declaration` names, in the same unordered walk that already poisons foreach targets and closure params. Name-targeted, not statement-targeted — `global $other;` leaves `$svc` resolvable, which the tests pin. Multi-name forms carry one `variable_name` per declared name and a static initializer is a constant expression, so sweeping the statement names exactly the rebound variables (AST shapes probed against tree-sitter-php 0.24.1). --- graphify/extractors/engine.py | 16 ++++- tests/test_php_member_calls.py | 124 +++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 231c080761..42aa5253b4 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -716,8 +716,9 @@ def _php_method_receiver_types( table entirely — whenever its binding is not provably single-typed: a rebind to anything but a `new`, two conflicting `new` types, an augmented assignment, a closure or arrow-function parameter shadowing it, a foreach - target, or a list-destructuring element. Poisoning is order-independent, - which is why it can be decided from a single unordered walk. + target, a list-destructuring element, or a `global`/`static` statement + rebinding it to other storage. Poisoning is order-independent, which is why + it can be decided from a single unordered walk. """ table = {f"this.{name}": type_name for name, type_name in field_types.items()} method_types: dict[str, str] = {} @@ -796,6 +797,17 @@ def new_type_name(node) -> str | None: targets = [c for c in node.named_children if c is not body_node] for target in targets[1:]: poison_bound_vars(target) + elif node.type in ("global_declaration", "function_static_declaration"): + # `global $svc;` / `static $svc;` rebind the NAME to DIFFERENT + # storage — the global slot, or the function-static slot that starts + # out null — so any type learned from a `new` in this body is stale + # (#13). Name-targeted, not statement-targeted: `global $other;` + # must leave `$svc`'s binding intact. Both multi-name forms + # (`global $a, $svc;` and `static $x = 1, $svc;`) carry one + # `variable_name` per declared name, and a static initializer is a + # constant expression, so sweeping the whole statement names exactly + # the rebound variables (shapes probe-verified). + poison_bound_vars(node) elif node.type == "augmented_assignment_expression": left = node.child_by_field_name("left") if left is not None and left.type == "variable_name": diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index f8bc0113e8..43e6fbee17 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -789,6 +789,130 @@ def test_list_destructuring_poisons_outer_name(tmp_path: Path): assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) +def test_global_statement_poisons_local(tmp_path: Path): + """`global $svc;` makes the name an alias of the GLOBAL slot — the local + `new` is discarded, so the type learned from it is stale (#13).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " global $svc;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "at runtime $svc is the global, never the locally constructed service" + + +def test_static_statement_poisons_local(tmp_path: Path): + """`static $svc;` rebinds the name to the function-static slot, which starts + out null and survives across calls.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " static $svc;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_global_statement_poisons_regardless_of_order(tmp_path: Path): + """Poisoning is order-independent: the raw calls carry no statement order, + so a `global` BEFORE the `new` must refuse just the same.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "global $svc;\n" + " $svc = new LeadHunterService();\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_multi_name_global_poisons_every_listed_name(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $log = new AuditLog();\n" + " global $log, $svc;\n" + " $svc->search([]);\n" + " return $log->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_multi_name_static_with_initializer_poisons_every_listed_name(tmp_path: Path): + """`static $x = 1, $svc;` declares two names; the constant initializer names + no variable, so exactly the declared ones are poisoned.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $log = new AuditLog();\n" + " static $x = 1, $log, $svc;\n" + " $svc->search([]);\n" + " return $log->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_global_statement_naming_another_variable_keeps_the_binding(tmp_path: Path): + """The poison is name-targeted, not statement-targeted: `global $other;` + says nothing about `$svc`, whose `new` still types it.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " global $other;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_static_statement_naming_another_variable_keeps_the_binding(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " static $conn = null;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + def test_new_inside_anonymous_class_does_not_bind_enclosing_name(tmp_path: Path): """An anonymous-class body is its own scope — its `new` must not type a same-named variable in the method that contains the literal.""" From 8d051c652de9d33709cc99a11950dbc71b984eb6 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 15:52:54 -0300 Subject: [PATCH 09/19] fix(php): keep the interface refusal across incremental rebuilds (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 refuses an interface-typed receiver, but only on a full build. Interface names reached the resolver through `per_file`, which aligns 1:1 with the files dispatched THIS run, and the incremental widening path (#2406/#2437/#2438) carried nodes and contains/method edges — no channel for names. So on a rebuild where the interface's own file was unchanged and therefore not dispatched, the refusal silently stopped applying and the lone same-short-named CLASS satisfied the single-definition guard. Worse than a missing edge: a wrong one. Measured before the fix, `graphify extract` twice on the Laravel Contracts collision (an `App\Contracts\Notifier` interface, an unrelated `App\Support\Notifier` class, a `private Notifier $notifier` receiver): FULL -> notify calls: [] INCREMENTAL -> notify calls: [(dispatcher_go, support_notifier_notify)] Of the two directions recorded on the issue, this takes B (stamp a marker on a node the incremental path already carries) over A (a third `extract()` context parameter): A would still need somewhere to persist the names, so it buys a wider public signature for the same node-marker plumbing. The host is the PHP FILE node — an interface mints no node of its own, and no definition nodes change — carrying `_php_interfaces` with the names listed explicitly, never inferred from the `.php` label (that holds only under one-interface- per-file PSR-4 convention). Like `_callable` (#2438) the marker is deliberately not popped, so it persists into graph.json, and watch.py / cli.py hand it back on the resolution-context nodes. extract() turns those names back into the resolver's EXISTING single channel: one synthetic `php_interfaces`-only `per_file` entry on the scratch list, so `_resolve_php_member_calls` reads one union and full/incremental agree by construction. The names are harvested from the RAW context-node list, not from the merged `resolution_nodes`: a changed caller that does `use App\Contracts\Notifier;` mints a sourceless import stub whose id IS the interface file node's id, and the merge drops the colliding context node (fresh wins) — measured, and it takes the marker with it exactly when the refusal is needed. Tests: four in test_php_member_calls.py drive the incremental path through the public extract() seam with resolution context assembled the way watch.py builds it from graph.json (field subset + markers, contains/method edges) — refusal, short-name collision, case-insensitivity, plus a positive control that a class-typed receiver still resolves. Three in test_watch.py go end-to-end through `_rebuild_code(changed_paths=...)`: refusal held, marker persisted on the interface file only, and a pre-marker graph neither crashes nor blocks the next full rebuild from self-healing. The first three of each are red without this change. Suite: 3994 passed / 36 skipped, plus the pre-existing environment-specific test_collect_files_skips_hidden failure (dotted worktree path). Co-Authored-By: Claude Opus 5 --- graphify/cli.py | 6 +- graphify/extract.py | 54 +++++++++- graphify/extractors/engine.py | 14 +++ graphify/watch.py | 5 +- tests/test_php_member_calls.py | 181 +++++++++++++++++++++++++++++++++ tests/test_watch.py | 110 ++++++++++++++++++++ 6 files changed, 364 insertions(+), 6 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index c7d9a87ea2..86783d2e00 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3147,7 +3147,11 @@ def _ctx_identity(source_file) -> str | None: "file_type": _node.get("file_type"), "type": _node.get("type"), } - for _marker in ("_callable", "_callable_class"): + # `_php_interfaces` (#11) rides the same marker channel + # as the callability flags: without it an unchanged PHP + # interface file stops refusing an interface-typed + # receiver and a stranger class gets the edge. + for _marker in ("_callable", "_callable_class", "_php_interfaces"): if _node.get(_marker): _ctx_node[_marker] = _node[_marker] _ctx_nodes.append(_ctx_node) diff --git a/graphify/extract.py b/graphify/extract.py index 390ec789d4..ba3f815208 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3085,6 +3085,30 @@ class name itself still resolved unambiguously. return len(parts) >= len(want) and parts[-len(want):] == want +def _php_context_interface_entry(context_nodes: list[dict] | None) -> dict | None: + """Recover the unchanged corpus's PHP interface names for the resolver (#11). + + A PHP interface mints no definition node, so the extractor stamps the names it + declared on the file's own node as ``_php_interfaces`` — a persisted marker, + like ``_callable`` (#2438) — and ``watch``/``graphify update`` hand it back on + the resolution-context nodes. Returns a synthetic ``per_file``-shaped entry + carrying just those names (or None when there are none), which extends the + resolver's existing single channel instead of adding a second one. + + Read from the RAW context list rather than off the merged resolution nodes: a + changed caller that does `use App\\Contracts\\Notifier;` mints a sourceless + import stub whose id IS the interface file node's id, and the merge drops the + colliding context node (fresh wins) — taking the marker with it, exactly in the + case the refusal is needed. + """ + names = sorted({ + str(name) + for node in (context_nodes or []) + for name in (node.get("_php_interfaces") or ()) + }) + return {"php_interfaces": names} if names else None + + def _resolve_php_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -3138,6 +3162,12 @@ def key(label: str) -> str: # to whatever same-named CLASS happens to exist — the Laravel Contracts # collision (`App\Contracts\Notifier` vs `App\Support\Notifier`), which the # single-definition guard cannot see because there IS only one definition. + # `per_file` aligns 1:1 with the files dispatched THIS run, so an incremental + # rebuild that leaves the interface's own file untouched used to see no + # interface names at all and mint a wrong edge into the same-short-named + # class (#11). extract() closes that hole by appending the unchanged corpus's + # persisted names as an extra `php_interfaces`-only entry, so this one channel + # still covers the whole corpus — see `_php_context_interface_entry`. interface_names = { key(name) for result in per_file @@ -4911,9 +4941,11 @@ def extract( `_callable_class` markers, #2438), and the member-call resolvers run by `run_language_resolvers` (#2437) — so a changed caller can still bind `foo()`, `obj.method()`, or `submit(handler)` to an - unchanged callee. They are never parsed, mutated, or returned; - raw_calls come only from `paths`, so only edges sourced by the - re-extracted files are emitted. + unchanged callee. They also carry the PHP resolver's interface + names, stamped as `_php_interfaces` on each PHP file node, so an + unchanged interface file keeps its refusal (#11). They are never + parsed, mutated, or returned; raw_calls come only from `paths`, so + only edges sourced by the re-extracted files are emitted. resolution_context_edges: the `contains`/`method` edges of the same unchanged corpus (#2437). The member-call resolvers walk these to map a receiver type to the single class owning the called method; @@ -5920,11 +5952,22 @@ def _has_import_evidence(candidate_id: str) -> bool: # results: raw_calls come solely from `paths`, so nothing sourced by an # unchanged file is ever emitted, and the ambiguity guards count the same # candidates a full build would (the context is the whole unchanged corpus). + # + # #11: nodes and edges are not the whole story — the PHP resolver also needs + # the unchanged corpus's INTERFACE names, which mint no node of their own. They + # ride in on the context nodes' `_php_interfaces` marker; hand them over as one + # extra `per_file` entry (scratch list, the real `per_file` is untouched) so an + # unchanged interface file keeps refusing an interface-typed receiver instead + # of letting it bind to a same-short-named class. if resolution_context_nodes or resolution_context_edges: _rl_nodes = list(resolution_nodes) _rl_edges = all_edges + list(resolution_context_edges or []) + _rl_per_file = per_file + _php_ctx_entry = _php_context_interface_entry(resolution_context_nodes) + if _php_ctx_entry is not None: + _rl_per_file = [*per_file, _php_ctx_entry] _n0, _e0 = len(_rl_nodes), len(_rl_edges) - run_language_resolvers(paths, per_file, _rl_nodes, _rl_edges) + run_language_resolvers(paths, _rl_per_file, _rl_nodes, _rl_edges) all_nodes.extend(_rl_nodes[_n0:]) all_edges.extend(_rl_edges[_e0:]) else: @@ -6064,6 +6107,9 @@ def _canon(nid: str) -> str: # label (that would reintroduce the #1566/#2137 data-symbol false positives); # a graph written before the markers existed simply fails closed until its # files are re-extracted. + # `_php_interfaces` (#11) is kept for the same reason and with the opposite + # failure direction: a pre-marker graph simply loses the interface refusal on + # an incremental rebuild until the interface's file is re-extracted. # local_alias is a transient import-resolution hint (#2082), same shape as # target_file (#1814): it exists only so the module arm of diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 24155af897..74f5c350ca 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5182,6 +5182,20 @@ def _scan_js_module_dispatch(n) -> None: # from a same-named class without this (#1682). Sorted for a stable # AST-cache payload. result["php_interfaces"] = sorted(php_interface_names) + # The per-file payload above only reaches the resolver for files + # dispatched THIS run, so on an incremental rebuild an unchanged + # interface file stopped refusing and the receiver bound to a stranger + # class sharing the short name (#11). Also stamp the names on the FILE + # node — the marker rides the node dict into graph.json and back in as + # resolution context, the same channel `_callable` uses (#2438). The + # file node is the host because an interface mints no node of its own; + # the names are listed explicitly rather than read off the node's + # `.php` label, which would only hold under one-interface-per-file + # PSR-4 convention. + for n in nodes: + if n["id"] == file_nid: + n["_php_interfaces"] = list(result["php_interfaces"]) + break # TS/JS: augment the constructor-injection type table with local `new` # bindings and type-annotated parameters, so `const s = new Svc(); s.m()` and # a call on a typed param (incl. inside a closure) resolve (#1630). The diff --git a/graphify/watch.py b/graphify/watch.py index 862997a682..ad9a8de668 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1212,7 +1212,10 @@ def _add_deleted_source(path: Path) -> None: # #2438: the persisted callability markers are the only # thing that lets an unchanged target pass the # indirect_call guard — never re-derived from the label. - for marker in ("_callable", "_callable_class"): + # `_php_interfaces` (#11) rides the same channel: it is the + # only way an unchanged PHP interface file keeps refusing an + # interface-typed receiver on an incremental rebuild. + for marker in ("_callable", "_callable_class", "_php_interfaces"): if node.get(marker): ctx_node[marker] = node[marker] resolution_context_nodes.append(ctx_node) diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 2ebe2f3ef3..6854c14752 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -1088,3 +1088,184 @@ def test_class_receiver_still_resolves_when_an_interface_exists(tmp_path: Path): assert (go, _find(r, ".notify()", "mailnotifier")) in calls assert (go, _find(r, ".notify()", "audittrail")) not in calls assert (go, _find(r, ".notify()", "support_notifier")) not in calls + + +# ── The refusal survives an incremental rebuild (#11) ───────────────────────── +# +# Every test above goes through ONE full extract(), where interface names reach +# the resolver through `per_file` — which aligns 1:1 with the files dispatched +# this run. `graphify update`/watch dispatch only the CHANGED files and hand the +# unchanged corpus back as read-only resolution context, so a refusal that lives +# only in `per_file` stopped applying the moment the interface's own file was not +# re-extracted, and the receiver bound to the same-short-named stranger class. +# The context below is assembled exactly as watch.py builds it from graph.json +# (watch.py:1205-1240): a FIELD SUBSET of the persisted AST nodes — id, label, +# source_file, file_type, type plus the persisted underscore markers — and the +# corpus's contains/method edges, both scoped to the files NOT being re-extracted. + +_CTX_NODE_FIELDS = ("label", "source_file", "file_type", "type") +_CTX_MARKERS = ("_callable", "_callable_class", "_php_interfaces") + + +def _watch_resolution_context(result: dict, unchanged: set[str]): + """Mirror watch.py's resolution-context assembly for the `unchanged` files.""" + nodes = [] + for node in result["nodes"]: + if not node.get("id") or node.get("source_file") not in unchanged: + continue + ctx = {"id": node["id"]} + ctx.update({field: node.get(field) for field in _CTX_NODE_FIELDS}) + ctx.update({m: node[m] for m in _CTX_MARKERS if node.get(m)}) + nodes.append(ctx) + edges = [ + { + "source": edge.get("source"), + "target": edge.get("target"), + "relation": edge.get("relation"), + "source_file": edge.get("source_file"), + } + for edge in result["edges"] + if edge.get("relation") in ("contains", "method") + and edge.get("source_file") in unchanged + ] + return nodes, edges + + +def _full_then_incremental(tmp_path: Path, files: dict[str, str], changed: str): + """Full-extract `files`, then re-extract ONLY `changed` (its body edited) with + the rest supplied as watch-shaped resolution context. + + Returns ((full_calls, full_result), (inc_calls, inc_result)). Both runs share + `cache_root`, the anchor watch passes, so node ids line up across them. + """ + corpus = tmp_path / "corpus" + paths = {} + for name, body in files.items(): + path = corpus / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths[name] = path + + def _calls_of(result): + return { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + + full = extract(list(paths.values()), cache_root=corpus) + # Edit the caller only — an unrelated statement, so its raw_calls are re-derived + # while every other file stays byte-identical and therefore undispatched. + paths[changed].write_text( + files[changed].replace("class ", "// touched\nclass ", 1), encoding="utf-8" + ) + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(files) - {changed} + ) + inc = extract( + [paths[changed]], + cache_root=corpus, + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges, + ) + return (_calls_of(full), full), (_calls_of(inc), inc) + + +_INCR_DISPATCHER = "app/Http/Dispatcher.php" + + +def test_interface_refusal_survives_incremental_rebuild(tmp_path: Path): + """The interface file is unchanged and therefore NOT dispatched: the refusal + must still fire, so the incremental run agrees with the full one (#11).""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + assert not _notified(full_calls, go), "full-build baseline must refuse" + assert not _notified(inc_calls, go), \ + "an undispatched interface file must not silently drop the refusal" + + +def test_interface_short_name_collision_emits_no_edge_incrementally(tmp_path: Path): + """The Laravel Contracts collision across a rebuild: `App\\Support\\Notifier` + is the lone DEFINITION under that short name, so the single-definition guard + alone would bind the receiver to the stranger.""" + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + # The stranger's node lives in an unchanged file, so its id comes from the + # full result — the incremental run returns only fresh nodes. + stranger = _find(full, ".notify()", "support_notifier") + assert (go, stranger) not in inc_calls, \ + "a rebuild must not bind a contract-typed receiver to a same-named class" + assert not _notified(inc_calls, go) + + +def test_interface_refusal_is_case_insensitive_incrementally(tmp_path: Path): + """PHP type names are case-insensitive on the incremental path too: the + persisted names are folded on both sides, never compared verbatim.""" + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls + assert not _notified(inc_calls, go) + + +def test_class_typed_receiver_still_resolves_incrementally(tmp_path: Path): + """Positive control for the two tests above: the refusal stays name-scoped + across a rebuild — a CLASS-typed receiver still binds into its unchanged + file (#2437), and the decoys still get nothing.""" + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + "app/Audit/AuditTrail.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + assert (go, _find(full, ".notify()", "mailnotifier")) in inc_calls, \ + "the incremental path must still resolve a class-typed receiver" + assert (go, _find(full, ".notify()", "audittrail")) not in inc_calls + assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls diff --git a/tests/test_watch.py b/tests/test_watch.py index 4854a6773c..26c88069d8 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -3153,3 +3153,113 @@ def test_incremental_indirect_call_parity_and_idempotency(tmp_path): fresh = _2438_seed(tmp_path / "fresh", caller_prefix=" x = 1\n") assert sorted(_2438_indirects(_2406_graph(fresh))) == sorted(incremental) + + +# --- #11: PHP interface refusal survives an incremental rebuild -------------- +# A PHP `interface` mints no definition node, so the resolver learns the names +# from the extractor (#1682). On a rebuild the interface's own file is usually +# unchanged and therefore never dispatched, so the names must come back through +# the persisted graph — the `_php_interfaces` marker on the file node, the same +# channel `_callable` uses (#2438). Without it the refusal stopped applying and +# an `App\Contracts\Notifier`-typed receiver bound to the unrelated +# `App\Support\Notifier` class: a WRONG edge, not just a missing one. + +_11_CALLER = ( + "notifier->notify('x'); }\n}\n" +) + + +def _11_seed(tmp_path, caller_extra=""): + """PHP corpus: a Notifier INTERFACE, an unrelated same-short-named CLASS, and + a Dispatcher whose receiver is typed as the interface. Full-rebuild it.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + (corpus / "app" / "Contracts").mkdir(parents=True) + (corpus / "app" / "Support").mkdir(parents=True) + (corpus / "app" / "Http").mkdir(parents=True) + (corpus / "app" / "Contracts" / "Notifier.php").write_text( + " Date: Wed, 5 Aug 2026 15:56:27 -0300 Subject: [PATCH 10/19] fix(php): corroborate an inline-`new` FQN against the declared namespace (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_php_qualified_corroborates` promoted a member call to EXTRACTED 1.0 whenever the written class name matched the TAIL of the resolved node's file path. Its docstring justified that with "PHP nodes carry no namespace" — but the `namespace` declaration sits in the source and was simply never read, and PSR-4 is a convention, not an invariant. Two names were stamped at maximum confidence while denoting a class that exists nowhere in the corpus: * `app/Services/Client.php` declaring `namespace App\Vendor;` made `(new \App\Services\Client())` corroborate `App\Vendor\Client` — a wrong TARGET at 1.0, not just an inflated score. PSR-0 leftovers, classmap autoloaders, moved files and generated code all produce this. * `\Services\Client` corroborated `App\Services\Client`, because a proper suffix of the real name still matched the path tail. A missing `use` plus a leading backslash is a common bug; it was being rewarded. Pre-scan each PHP file's `namespace` declarations (both the statement and the braced-block form) at extraction time, map every class it declares to its fully qualified name, and thread that to the resolver keyed by defining file. When the declaration is known the comparison is whole-name, so neither exploit promotes. Only files that declare NO namespace fall back to the PSR-4 path check — with nothing declared, the path is the only evidence there is. A mismatch still downgrades rather than refusing, per #3's shipped policy, and a bare `new Svc()` still stays INFERRED. --- graphify/extract.py | 54 ++++++++++++++-- graphify/extractors/engine.py | 66 ++++++++++++++++++++ tests/test_php_member_calls.py | 110 +++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 6 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index fa7f770c96..884fb3cf7e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3055,17 +3055,32 @@ def key(label: str) -> str: _OBJC_RESOLVER_SUFFIXES = (".m", ".mm", ".h") -def _php_qualified_corroborates(qualified: str | None, type_node: dict | None) -> bool: +def _php_qualified_corroborates( + qualified: str | None, + type_node: dict | None, + declared_fqn: str | None = None, +) -> bool: """True when a source-written class name corroborates the resolved node (#1682). ``(new \\App\\Services\\Svc())`` names the class outright, but the short-name lookup that found the node ignored the namespace — so the namespace is independent evidence, and only a match makes the edge EXTRACTED. - The check is deliberately narrow: PHP nodes carry no namespace, so the only - corroborating fact available here is the node's path, and PSR-4 maps - ``App\\Services\\Svc`` onto ``app/Services/Svc.php``. Every segment of the - written name must line up with the tail of that path, case-insensitively. + ``declared_fqn`` is the name the DEFINING FILE declares for that class, + read from its ``namespace`` statement at extraction time (#14). When it is + known the comparison is whole-name: `\\App\\Services\\Client` corroborates + `App\\Services\\Client` and nothing else. Two things that used to promote + now do not — a file whose declared namespace disagrees with its PSR-4 path + (the written name then denotes a class that exists nowhere in the corpus), + and a truncated qualifier like `\\Services\\Client`, which is a DIFFERENT + class from `App\\Services\\Client` but matched as a path tail. + + Without a declaration — the file declares no namespace at all, or the node + came from a prior graph on an incremental run — the node's path is the only + corroborating fact left, and PSR-4 maps ``App\\Services\\Svc`` onto + ``app/Services/Svc.php``; every written segment must line up with the tail + of that path, case-insensitively. + A BARE name (no namespace segment) corroborates nothing and stays INFERRED; a namespace that does not line up downgrades rather than refusing, since the class name itself still resolved unambiguously. @@ -3075,6 +3090,9 @@ class name itself still resolved unambiguously. want = [seg.casefold() for seg in str(qualified).split("\\") if seg] if len(want) < 2: return False # bare `new Svc()`: no namespace written, no evidence + if declared_fqn: + have = [seg.casefold() for seg in str(declared_fqn).split("\\") if seg] + return want == have # whole name, not a suffix of one source_file = str(type_node.get("source_file") or "") parts = [p for p in source_file.replace("\\", "/").split("/") if p and p not in (".", "..")] @@ -3149,6 +3167,27 @@ def key(label: str) -> str: for name in result.get(keyname, []) } + # Fully qualified class names as each defining file DECLARES them (#14), so + # the inline-`new` corroboration below compares the written name against the + # real one instead of against the file's path, which PSR-4 only conventionally + # agrees with. Keyed by defining file, then by short class name. + class_fqn_by_file: dict[str, dict[str, str]] = {} + for result in per_file: + declared = result.get("php_class_fqns") + if declared and declared.get("path"): + class_fqn_by_file[declared["path"]] = declared.get("classes", {}) + + def declared_fqn(type_node: dict | None) -> str | None: + """The namespace-qualified name of ``type_node``'s class, if its file + declared one. Absent for a global-namespace class, and for a node + replayed from a prior graph on an incremental run.""" + if not type_node: + return None + by_name = class_fqn_by_file.get(str(type_node.get("source_file") or "")) + if not by_name: + return None + return by_name.get(key(type_node.get("label", ""))) + 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", []): @@ -3182,8 +3221,11 @@ def key(label: str) -> str: if receiver == "(new)": # The class is named in the source; promote to EXTRACTED # only when the written namespace backs the node we found. + type_node = node_by_id.get(type_nid) exact = _php_qualified_corroborates( - raw_call.get("receiver_qualified"), node_by_id.get(type_nid) + raw_call.get("receiver_qualified"), + type_node, + declared_fqn(type_node), ) method_nids = method_index.get((type_nid, key(callee)), set()) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 42aa5253b4..57e1248c9c 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -180,6 +180,64 @@ def _php_pre_scan_non_class_declarations(root_node, source: bytes) -> set[str]: return out +def _php_pre_scan_class_namespaces(root_node, source: bytes) -> dict[str, str]: + """Map every namespaced class in this PHP file to its fully qualified name (#14). + + PHP class NODES carry no namespace, so the inline-`new` corroboration in + ``_php_qualified_corroborates`` had only the file's path to compare a + written ``\\App\\Services\\Client`` against. PSR-4 is a convention, not an + invariant: a file at ``app/Services/Client.php`` may declare + ``namespace App\\Vendor;`` (PSR-0 leftovers, classmap autoloaders, moved + files, generated code), and the written name then corroborates a class that + exists nowhere. The declaration is right there in the source — read it. + + Both namespace forms are handled: ``namespace X;`` applies to the + declarations that follow it (a file may switch namespaces mid-way), and + ``namespace X { … }`` applies to its block. A class declared in NO namespace + is deliberately absent from the map: the file states nothing, so the + resolver falls back to the path check rather than refusing. A short name + declared twice under different namespaces in one file is dropped — the map + is keyed by short name, and a wrong answer is worse than no answer. + """ + out: dict[str, str] = {} + conflicting: set[str] = set() + + # Each entry carries the namespace in force where it was queued, so the + # scopes stay right without walking siblings in order. A class body is never + # descended into: PHP has no nested class declarations, and an anonymous + # class inside a method names nothing. + stack = [(root_node, "")] + while stack: + node, namespace = stack.pop() + current = namespace + for child in node.children: + if child.type == "namespace_definition": + name_node = child.child_by_field_name("name") + # A braced `namespace { … }` names nothing: the global namespace. + declared = (_read_text(name_node, source).strip("\\") + if name_node is not None else "") + body = child.child_by_field_name("body") + if body is not None: + stack.append((body, declared)) + else: + current = declared # applies to the declarations that follow + continue + if child.type == "class_declaration": + name_node = child.child_by_field_name("name") + name = _read_text(name_node, source) if name_node is not None else "" + if name and current: + fqn = f"{current}\\{name}" + short = name.casefold() + if out.setdefault(short, fqn) != fqn: + conflicting.add(short) + continue + if child.is_named: + stack.append((child, current)) + for short in conflicting: + out.pop(short, None) + return out + + def _csharp_classify_base(name: str, interface_names: set[str]) -> str: """`implements` if the base name is an interface (declared or by I-prefix convention), else `inherits`.""" if name in interface_names: @@ -2606,8 +2664,10 @@ def _extract_generic( csharp_interface_names = _csharp_pre_scan_interfaces(root, source) php_non_class_type_names: set[str] = set() + php_class_fqns: dict[str, str] = {} if config.ts_module == "tree_sitter_php": php_non_class_type_names = _php_pre_scan_non_class_declarations(root, source) + php_class_fqns = _php_pre_scan_class_namespaces(root, source) swift_protocol_names: set[str] = set() swift_class_names: set[str] = set() @@ -5205,6 +5265,12 @@ def _scan_js_module_dispatch(n) -> None: # cannot tell one from a same-named class without this (#1682). Sorted # for a stable AST-cache payload. result["php_non_class_types"] = sorted(php_non_class_type_names) + if php_class_fqns: + # The `namespace` this file declares for each class it defines, so the + # inline-`new` corroboration can compare a written FQN against the real + # one instead of guessing from the path (#14). Same `{"path": …}` shape + # as the type tables, which the cache re-anchors on load. + result["php_class_fqns"] = {"path": str_path, "classes": php_class_fqns} # TS/JS: augment the constructor-injection type table with local `new` # bindings and type-annotated parameters, so `const s = new Svc(); s.m()` and # a call on a typed param (incl. inside a closure) resolve (#1630). The diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 43e6fbee17..35e9b67cba 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -466,6 +466,116 @@ def test_inline_new_non_corroborating_namespace_downgrades(tmp_path: Path): assert edge["confidence_score"] == 0.8 +# The corroborating fact is the namespace the DEFINING FILE declares (#14). +# PSR-4 is a convention, not an invariant, so the path alone promoted two wrong +# names to EXTRACTED 1.0: one naming a class that exists nowhere in the corpus +# (declared namespace ≠ path), and one naming a different class that merely +# matched as a path tail. The path survives only as the fallback for a file +# that declares no namespace at all. + + +def test_declared_namespace_disagreeing_with_the_path_does_not_promote(tmp_path: Path): + """`app/Services/LeadHunterService.php` declaring `namespace App\\Vendor;` + means `App\\Services\\LeadHunterService` exists NOWHERE — the short name + still resolves to the one definition, but at INFERRED, not 1.0.""" + calls, r = _calls(tmp_path, { + "app/Services/LeadHunterService.php": ( + "search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + + +def test_truncated_root_namespace_does_not_corroborate(tmp_path: Path): + """`\\Services\\LeadHunterService` is a ROOT-namespace class, a different + one from `App\\Services\\LeadHunterService` — a missing `use` plus a leading + backslash is a common bug and must not be rewarded with 1.0.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\Services\\LeadHunterService())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + + +def test_braced_namespace_block_corroborates(tmp_path: Path): + """`namespace App\\Services { … }` declares the same fact as the statement + form, so the whole-name match still promotes.""" + calls, r = _calls(tmp_path, { + "app/Services/LeadHunterService.php": ( + "search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + assert calls[(index, service_search)]["confidence"] == "EXTRACTED" + + +def test_file_declaring_no_namespace_still_corroborates_by_path(tmp_path: Path): + """A file that declares nothing leaves the PSR-4 path as the only evidence + there is — unchanged behaviour, deliberately kept.""" + calls, r = _calls(tmp_path, { + "app/Services/LeadHunterService.php": ( + "search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + assert calls[(index, service_search)]["confidence"] == "EXTRACTED" + + +def test_written_namespace_match_is_case_insensitive(tmp_path: Path): + """PHP namespaces are case-insensitive, so `\\app\\services\\…` names the + same class the file declares.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\app\\services\\LeadHunterService())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + assert calls[(index, service_search)]["confidence"] == "EXTRACTED" + + def test_inline_new_beats_same_file_same_named_method(tmp_path: Path): """The named class wins over an identically named method in the caller's own file — the bare-name match must not shadow an explicit `new`.""" From 85bb8df93b44f67a944a28150027f0a888a49423 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 16:08:28 -0300 Subject: [PATCH 11/19] fix(php): carry enum and trait names across incremental rebuilds too (#11, #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #11 gave the refusal a second channel for rebuilds: a PHP file stamps the names it declares onto its own file node, that marker persists into graph.json, and watch/`graphify extract` hand it back as resolution context, which extract() folds into the resolver's single `per_file` channel. It carried INTERFACE names only. #12 had meanwhile widened the refusal to enums and traits — on the full build. So an unchanged `App\Enums\Status` file reached the resolver through nothing at all on a rebuild, `App\Legacy\Status` became the one visible definition, and #12's wrong edge came straight back on the incremental path. Measured, `/tmp/rt6/probe_incr_enum.py` (context marker stripped to simulate the interface-only channel): `FULL -> (no calls)`, `INCREMENTAL -> .go() -> label()`. Extend the channel to all three declaration kinds. The marker is renamed `_php_interfaces` -> `_php_non_class_types` since its contents no longer match the old name, and every reader — extract()'s context harvester, watch.py's and cli.py's marker tuples — still accepts the old spelling, so a graph.json written before this keeps refusing the interfaces it names instead of losing the channel outright. Same dual-read tolerance the per-file `php_interfaces` key already has. Verified end to end: `graphify extract . --code-only` twice over an enum corpus reports "2 files cached/unchanged, 1 re-extracted" with the marker persisted and no wrong edge (`/tmp/rt6/probe_cli_enum.py`). Tests: 5 through the extract() seam (enum property, enum typed param, trait, plus a class-typed positive control and one pinning that the legacy `_php_interfaces` spelling still refuses), 2 end-to-end through `_rebuild_code(changed_paths=…)`. With the marker temporarily reduced to interfaces, exactly the 3 refusal tests and the 2 watch tests go red. --- graphify/cli.py | 13 +-- graphify/extract.py | 54 ++++++----- graphify/extractors/engine.py | 7 +- graphify/watch.py | 12 ++- tests/test_php_member_calls.py | 158 ++++++++++++++++++++++++++++++++- tests/test_watch.py | 100 ++++++++++++++++++++- 6 files changed, 309 insertions(+), 35 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index 86783d2e00..dc8b03621e 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3147,11 +3147,14 @@ def _ctx_identity(source_file) -> str | None: "file_type": _node.get("file_type"), "type": _node.get("type"), } - # `_php_interfaces` (#11) rides the same marker channel - # as the callability flags: without it an unchanged PHP - # interface file stops refusing an interface-typed - # receiver and a stranger class gets the edge. - for _marker in ("_callable", "_callable_class", "_php_interfaces"): + # `_php_non_class_types` (#11, #12) rides the same + # marker channel as the callability flags: without it an + # unchanged PHP file declaring an interface, enum or + # trait stops refusing such a receiver and a stranger + # class gets the edge. `_php_interfaces` is the pre-#12 + # spelling, still carried for older graphs. + for _marker in ("_callable", "_callable_class", + "_php_non_class_types", "_php_interfaces"): if _node.get(_marker): _ctx_node[_marker] = _node[_marker] _ctx_nodes.append(_ctx_node) diff --git a/graphify/extract.py b/graphify/extract.py index e3024af490..447ae4021c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3103,15 +3103,23 @@ class name itself still resolved unambiguously. return len(parts) >= len(want) and parts[-len(want):] == want +_PHP_NON_CLASS_TYPE_MARKERS = ("_php_non_class_types", "_php_interfaces") + + def _php_context_interface_entry(context_nodes: list[dict] | None) -> dict | None: - """Recover the unchanged corpus's PHP interface names for the resolver (#11). + """Recover the unchanged corpus's PHP interface/enum/trait names (#11, #12). + + None of the three mints a definition node, so the extractor stamps the names a + file declared on that file's own node as ``_php_non_class_types`` — a persisted + marker, like ``_callable`` (#2438) — and ``watch``/``graphify update`` hand it + back on the resolution-context nodes. Returns a synthetic ``per_file``-shaped + entry carrying just those names (or None when there are none), which extends + the resolver's existing single channel instead of adding a second one. - A PHP interface mints no definition node, so the extractor stamps the names it - declared on the file's own node as ``_php_interfaces`` — a persisted marker, - like ``_callable`` (#2438) — and ``watch``/``graphify update`` hand it back on - the resolution-context nodes. Returns a synthetic ``per_file``-shaped entry - carrying just those names (or None when there are none), which extends the - resolver's existing single channel instead of adding a second one. + ``_php_interfaces`` is the pre-#12 spelling of the same marker, carrying + interfaces alone; it is still read so a graph.json written before enums and + traits joined the set keeps refusing the names it does carry, rather than + losing the refusal outright until its files are re-extracted. Read from the RAW context list rather than off the merged resolution nodes: a changed caller that does `use App\\Contracts\\Notifier;` mints a sourceless @@ -3122,9 +3130,10 @@ def _php_context_interface_entry(context_nodes: list[dict] | None) -> dict | Non names = sorted({ str(name) for node in (context_nodes or []) - for name in (node.get("_php_interfaces") or ()) + for marker in _PHP_NON_CLASS_TYPE_MARKERS + for name in (node.get(marker) or ()) }) - return {"php_interfaces": names} if names else None + return {"php_non_class_types": names} if names else None def _resolve_php_member_calls( @@ -4992,9 +5001,10 @@ def extract( `_callable_class` markers, #2438), and the member-call resolvers run by `run_language_resolvers` (#2437) — so a changed caller can still bind `foo()`, `obj.method()`, or `submit(handler)` to an - unchanged callee. They also carry the PHP resolver's interface - names, stamped as `_php_interfaces` on each PHP file node, so an - unchanged interface file keeps its refusal (#11). They are never + unchanged callee. They also carry the PHP resolver's interface, + enum and trait names, stamped as `_php_non_class_types` on each PHP + file node, so an unchanged declaring file keeps its refusal (#11, + #12). They are never parsed, mutated, or returned; raw_calls come only from `paths`, so only edges sourced by the re-extracted files are emitted. resolution_context_edges: the `contains`/`method` edges of the same @@ -6004,12 +6014,12 @@ def _has_import_evidence(candidate_id: str) -> bool: # unchanged file is ever emitted, and the ambiguity guards count the same # candidates a full build would (the context is the whole unchanged corpus). # - # #11: nodes and edges are not the whole story — the PHP resolver also needs - # the unchanged corpus's INTERFACE names, which mint no node of their own. They - # ride in on the context nodes' `_php_interfaces` marker; hand them over as one - # extra `per_file` entry (scratch list, the real `per_file` is untouched) so an - # unchanged interface file keeps refusing an interface-typed receiver instead - # of letting it bind to a same-short-named class. + # #11/#12: nodes and edges are not the whole story — the PHP resolver also + # needs the unchanged corpus's INTERFACE, ENUM and TRAIT names, which mint no + # node of their own. They ride in on the context nodes' `_php_non_class_types` + # marker; hand them over as one extra `per_file` entry (scratch list, the real + # `per_file` is untouched) so an unchanged declaring file keeps refusing such + # a receiver instead of letting it bind to a same-short-named class. if resolution_context_nodes or resolution_context_edges: _rl_nodes = list(resolution_nodes) _rl_edges = all_edges + list(resolution_context_edges or []) @@ -6158,9 +6168,11 @@ def _canon(nid: str) -> str: # label (that would reintroduce the #1566/#2137 data-symbol false positives); # a graph written before the markers existed simply fails closed until its # files are re-extracted. - # `_php_interfaces` (#11) is kept for the same reason and with the opposite - # failure direction: a pre-marker graph simply loses the interface refusal on - # an incremental rebuild until the interface's file is re-extracted. + # `_php_non_class_types` (#11, #12) is kept for the same reason and with the + # opposite failure direction: a pre-marker graph simply loses the refusal on + # an incremental rebuild until the declaring file is re-extracted. A graph + # carrying only the pre-#12 `_php_interfaces` spelling keeps refusing the + # interfaces it names — both spellings are read. # local_alias is a transient import-resolution hint (#2082), same shape as # target_file (#1814): it exists only so the module arm of diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 35e0b2915c..bd7bbc9349 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5274,10 +5274,13 @@ def _scan_js_module_dispatch(n) -> None: # file node is the host because none of these declarations mints a node # of its own; the names are listed explicitly rather than read off the # node's `.php` label, which would only hold under - # one-declaration-per-file PSR-4 convention. + # one-declaration-per-file PSR-4 convention. `_php_interfaces` is the + # pre-#12 spelling, carrying interfaces alone; readers still accept it, + # so a graph.json written before enums and traits joined the set keeps + # refusing what it does name. for n in nodes: if n["id"] == file_nid: - n["_php_interfaces"] = list(result["php_non_class_types"]) + n["_php_non_class_types"] = list(result["php_non_class_types"]) break if php_class_fqns: # The `namespace` this file declares for each class it defines, so the diff --git a/graphify/watch.py b/graphify/watch.py index ad9a8de668..043a75fc71 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1212,10 +1212,14 @@ def _add_deleted_source(path: Path) -> None: # #2438: the persisted callability markers are the only # thing that lets an unchanged target pass the # indirect_call guard — never re-derived from the label. - # `_php_interfaces` (#11) rides the same channel: it is the - # only way an unchanged PHP interface file keeps refusing an - # interface-typed receiver on an incremental rebuild. - for marker in ("_callable", "_callable_class", "_php_interfaces"): + # `_php_non_class_types` (#11, #12) rides the same channel: + # it is the only way an unchanged PHP file declaring an + # interface, enum or trait keeps refusing such a receiver on + # an incremental rebuild. `_php_interfaces` is that marker's + # pre-#12 spelling, carried so a graph.json written before + # enums and traits joined the set still round-trips. + for marker in ("_callable", "_callable_class", + "_php_non_class_types", "_php_interfaces"): if node.get(marker): ctx_node[marker] = node[marker] resolution_context_nodes.append(ctx_node) diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 195e3de38e..a8ef926fc3 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -1555,7 +1555,8 @@ def test_class_receiver_still_resolves_when_an_enum_exists(tmp_path: Path): # corpus's contains/method edges, both scoped to the files NOT being re-extracted. _CTX_NODE_FIELDS = ("label", "source_file", "file_type", "type") -_CTX_MARKERS = ("_callable", "_callable_class", "_php_interfaces") +_CTX_MARKERS = ("_callable", "_callable_class", "_php_non_class_types", + "_php_interfaces") def _watch_resolution_context(result: dict, unchanged: set[str]): @@ -1720,3 +1721,158 @@ def test_class_typed_receiver_still_resolves_incrementally(tmp_path: Path): "the incremental path must still resolve a class-typed receiver" assert (go, _find(full, ".notify()", "audittrail")) not in inc_calls assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls + + +# The same channel carries ENUM and TRAIT names (#12). They mint no definition +# node either, so an unchanged `App\Enums\Status` file that reaches the resolver +# through nothing but the persisted marker leaves `App\Legacy\Status` as the one +# visible definition — the wrong edge #12 closed on the full-build path, coming +# straight back on the incremental one. + +_INCR_RUNNER = "app/Http/Runner.php" + + +def _incr_enum_corpus(body: str) -> dict[str, str]: + return { + **_ENUM_CORPUS, + _INCR_RUNNER: ( + "status->label(); }" + ), + changed=_INCR_RUNNER, + ) + + go = _find(inc, ".go()", "runner") + stranger = _find(full, ".label()", "legacy_status") + assert not _labelled(full_calls, go), "full-build baseline must refuse" + assert (go, stranger) not in inc_calls, \ + "an undispatched enum file must not hand the edge to App\\Legacy\\Status" + assert not _labelled(inc_calls, go) + + +def test_enum_typed_param_refusal_survives_incremental_rebuild(tmp_path: Path): + """The typed-parameter entry point refuses across a rebuild too.""" + (_, full), (inc_calls, inc) = _full_then_incremental( + tmp_path, + _incr_enum_corpus(" public function go(Status $s): void { $s->label(); }"), + changed=_INCR_RUNNER, + ) + + go = _find(inc, ".go()", "runner") + assert (go, _find(full, ".label()", "legacy_status")) not in inc_calls + assert not _labelled(inc_calls, go) + + +def test_trait_refusal_survives_incremental_rebuild(tmp_path: Path): + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Support/Cache.php": ( + "cache->flush(); }\n" + "}\n" + ), + }, changed=_INCR_RUNNER) + + go = _find(inc, ".go()", "runner") + assert (go, _find(full, ".flush()", "legacy_cache")) not in inc_calls + assert not any(src == go and "flush" in tgt.lower() for src, tgt in inc_calls) + + +def test_class_typed_receiver_still_resolves_incrementally_beside_an_enum(tmp_path: Path): + """Positive control for the three above: the refusal stays name-scoped on the + incremental path — a CLASS-typed receiver still binds into its unchanged + file, with an unrelated enum and a same-named-method decoy in the corpus.""" + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + "app/Models/Lead.php": ( + "lead->label(); }\n" + "}\n" + ), + }, changed=_INCR_RUNNER) + + go = _find(inc, ".go()", "runner") + assert (go, _find(full, ".label()", "models_lead")) in inc_calls, \ + "the incremental path must still resolve a class-typed receiver" + assert (go, _find(full, ".label()", "audittrail")) not in inc_calls + + +def test_legacy_php_interfaces_marker_spelling_is_still_read(tmp_path: Path): + """Cache compatibility: a graph.json written before #12 carries the names + under `_php_interfaces`. Interfaces it names keep refusing — the rename must + not silently drop a channel that older graphs are still using.""" + (_, full), _ = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(_IFACE_CORPUS) + ) + downgraded = 0 + for node in ctx_nodes: + names = node.pop("_php_non_class_types", None) + if names: + node["_php_interfaces"] = names # the pre-#12 spelling + downgraded += 1 + assert downgraded == 1, "exactly the interface's file node carries the names" + + caller = tmp_path / "corpus" / _INCR_DISPATCHER + inc = extract([caller], cache_root=tmp_path / "corpus", + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges) + inc_calls = { + (edge["source"], edge["target"]) + for edge in inc["edges"] if edge.get("relation") == "calls" + } + + go = _find(inc, ".go()", "dispatcher") + assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls + assert not _notified(inc_calls, go) diff --git a/tests/test_watch.py b/tests/test_watch.py index 26c88069d8..32f770afb0 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -3231,9 +3231,9 @@ def test_incremental_rebuild_persists_php_interface_marker(tmp_path): corpus = _11_seed(tmp_path) stamped = { - node.get("source_file"): node["_php_interfaces"] + node.get("source_file"): node["_php_non_class_types"] for node in _2406_graph(corpus).get("nodes", []) - if node.get("_php_interfaces") + if node.get("_php_non_class_types") } assert stamped == {"app/Contracts/Notifier.php": ["Notifier"]}, \ "only the interface's own file carries the names" @@ -3251,6 +3251,7 @@ def test_incremental_rebuild_php_interface_legacy_graph_self_heals(tmp_path): graph_path = corpus / "graphify-out" / "graph.json" legacy = json.loads(graph_path.read_text(encoding="utf-8")) for node in legacy.get("nodes", []): + node.pop("_php_non_class_types", None) node.pop("_php_interfaces", None) graph_path.write_text(json.dumps(legacy), encoding="utf-8") @@ -3263,3 +3264,98 @@ def test_incremental_rebuild_php_interface_legacy_graph_self_heals(tmp_path): # A full rebuild re-extracts the interface file and restores marker + refusal. assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True assert _11_notify_calls(_2406_graph(corpus)) == [] + + +# --- #12: the same channel must carry ENUM and TRAIT names ------------------- +# `enum` and `trait` mint no definition node either, so an unchanged +# `App\Enums\Status` file reaches the resolver through the persisted marker +# alone. Carrying interfaces only there put the #12 wrong edge straight back on +# the incremental path: `App\Legacy\Status` becomes the one visible definition. + +_12_RUNNER = ( + "status->label(); $this->cache->flush(); }\n}\n" +) + + +def _12_seed(tmp_path, caller_extra=""): + """PHP corpus: a Status ENUM and a Cache TRAIT, each beside an unrelated + same-short-named CLASS, plus a Runner typed against both. Full-rebuild it.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + for sub in ("Enums", "Support", "Legacy", "Http"): + (corpus / "app" / sub).mkdir(parents=True) + (corpus / "app" / "Enums" / "Status.php").write_text( + ".php` label, which + cannot tell a declaration kind apart from the colliding class's file.""" + corpus = _12_seed(tmp_path) + + stamped = { + node.get("source_file"): node["_php_non_class_types"] + for node in _2406_graph(corpus).get("nodes", []) + if node.get("_php_non_class_types") + } + assert stamped == { + "app/Enums/Status.php": ["Status"], + "app/Support/Cache.php": ["Cache"], + }, "only the declaring files carry the names — the stranger classes carry none" From db7c8f84bc5f7c9f086e3ac964e64ec2586a7dbd Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 16:28:18 -0300 Subject: [PATCH 12/19] chore: bump to 0.9.34; changelog and docs for #1682 (#2/#3/#4/#5/#6/#8/#11/#12/#13/#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the PHP receiver-typed member-call work as 0.9.34. The changelog entry names what now resolves (typed properties and constructor-promoted params via `$this->prop`, nullsafe receivers, typed params, `$var = new T()` locals, and inline `(new T())->m()` as the one EXTRACTED form, gated on declared-namespace corroboration) and, at equal length, what is deliberately refused — untyped, union- and intersection-typed receivers, interface/enum/trait-typed receivers including across incremental rebuilds, corpus-duplicate short names, methods the receiver's class does not declare (so `__call` fabricates nothing), chained and array-element receivers, locals rebound or rebound by `global`/`static`, closure /arrow/foreach/destructuring shadowing, anonymous classes, and `self`/`static`/`parent`. Three behaviour deltas are called out because consumers weigh edges by confidence: a same-file call through a TYPED receiver moves EXTRACTED -> INFERRED 0.8 (measured on the base commit vs head, `/tmp/probe7_changelog_claims.py`); a qualified inline `new` is EXTRACTED while the same name written as a local stays INFERRED; and the language-scoped receiver index is two fixes, not one — polyglot corpora stop leaking cross-language edges AND regain PHP/ObjC edges a foreign same-short-named class used to suppress. Recall gaps (traits, inherited methods, enum methods as targets, typed params in top-level functions) and the use-alias-outside-corpus false-positive risk are named, as are the three items still open against this work. The docs confidence section gains a note that the member-call resolvers are a deterministic 0.8 INFERRED source distinct from the LLM rubric, plus the PHP refusal policy. The version bump rolls the version-namespaced AST cache. Verified end to end on the live repro corpus: pre-feature code fills `cache/ast/v0.9.33/`; head code at 0.9.33 serves those stale entries and produces NO receiver-aware edges even with all five files re-dispatched; at 0.9.34 the namespace misses, the corpus is re-parsed, and all three expected edges appear — `leadcontroller_index -> leadhunterservice_search` INFERRED 0.8, `paymentcontroller_store -> mixedpaymentservice_resolve` EXTRACTED 1.0, and the static control `paymentcontroller_store -> sucursalcontext` INFERRED 0.8 unchanged (`/tmp/probe7_bump_control.py`, `/tmp/probe7_cache_boundary.py`). The bump does not by itself force a re-extraction — an unchanged stat index short-circuits before the AST cache is consulted — so the changelog tells users to run `graphify update .` or drop `manifest.json`. The AST shapes the resolution reads are probed across every tree-sitter-php version pyproject accepts (0.23.0 through 0.24.1, twelve releases): 80/80 shape assertions hold on each, including the anonymous-class and `self`/`static` in type position cases, so the floor stays at >=0.23 (`/tmp/probe7_php_grammar.py`, `/tmp/probe7_php_versions.sh`). The 85 PHP tests also pass under 0.23.0, 0.23.5 and 0.23.11. uv.lock carries the one line that has to change: uv 0.12.1 rewrites 106 marker lines on a full `uv lock`, so the graphifyy version line is edited on its own. `uv lock --check` passes afterwards, which it did not before (the lock had been left at 0.9.31 across the 0.9.32 and 0.9.33 bumps). Suite: 4024 passed, 36 skipped — unchanged from the pre-release baseline. --- CHANGELOG.md | 10 ++++++++++ docs/how-it-works.md | 4 ++++ pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 148fd60e58..4c40030a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.9.34 (unreleased) + +- Feature: PHP instance-method calls on a typed receiver now resolve to the method they really reach, instead of a bare same-name match (#1682). `$this->prop->method()` binds to the type declared on the property — including a constructor-promoted param — and the same typing covers nullsafe receivers (`$obj?->method()`), natively typed parameters, and `$var = new T()` locals. `(new Service())->method()` resolves too and is the one form tagged EXTRACTED (1.0), since the class is named right there in the source; the promotion applies only when the written namespace corroborates the class node found for it, compared against the namespace the defining file DECLARES rather than against its PSR-4 path (a file at `app/Services/Client.php` may well declare `namespace App\Vendor;`). Every other typed receiver is INFERRED (0.8). The motivating case is a Laravel corpus: `$this->leadHunter->search(...)` used to reach nothing, and now reaches `App\Services\LeadHunterService::search`. +- Refuse-don't-guess is the policy wherever the receiver's type is not provably one concrete in-corpus class, so all of these deliberately emit NO edge: untyped, union-typed (`A|B`) and intersection-typed (`A&B`) receivers; receivers typed by an `interface`, `enum` or `trait` — none of which mints a definition node, so binding one would pick a same-short-named stranger (the `App\Contracts\Notifier` vs `App\Support\Notifier` collision, or `App\Enums\Status` beside an Eloquent `App\Models\Status`), and that refusal now survives an incremental rebuild by persisting those names on the declaring file's node; a short type name that does not match exactly one class in the corpus; a method the receiver's own class does not declare, so `__call` magic dispatch fabricates nothing; chained (`$this->factory()->method()`) and array-element (`$bag['k']->method()`) receivers; a local rebound to anything but a matching `new`, or rebound to other storage by `global`/`static`; a name shadowed by a closure or arrow-function parameter, a `foreach` target, or list destructuring; anonymous classes (`new class { ... }`); and `self`/`static`/`parent` in type position, which need inheritance context the raw-call facts do not carry. +- Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. +- Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. +- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. +- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`). +- The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. + ## 0.9.33 (unreleased) - Fix: the C# `partial class` merge (#2332) no longer conflates two same-named classes that live in different assemblies (#2411, thanks @JensD-git). The merge now keys on assembly (nearest ancestor directory containing a `.csproj`/`.fsproj`/`.vbproj`) in addition to namespace and name, so genuine partial halves within one project still merge while same-name types in separate projects stay distinct. A corpus with no project file keeps merging by namespace and name as before. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index e0e6e5275d..3d78c1f9b0 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -48,6 +48,10 @@ EXTRACTED edges always have confidence 1.0. INFERRED edges use a discrete rubric - **0.65** — weak (naming similarity only) - **0.55** — speculative +That rubric describes edges Claude inferred. The per-language member-call resolvers are a separate, deterministic source of INFERRED edges: they read the receiver's declared type out of the AST and bind at a fixed **0.8**, reserving EXTRACTED (1.0) for a receiver whose type is named in the source at the call site. + +**PHP member calls refuse rather than guess.** `$this->prop->method()`, `$obj?->method()`, a typed parameter and a `$var = new T()` local all bind to the receiver's declared type as INFERRED 0.8; `(new Service())->method()` is EXTRACTED 1.0, but only when the namespace written at the call site corroborates the class that was found. When the type is not provably one concrete in-corpus class, no edge is emitted at all — untyped, union- and intersection-typed receivers, receivers typed by an `interface`, `enum` or `trait`, a short name that matches two classes, a method the receiver's class does not declare, chained and array-element receivers, a local rebound or shadowed anywhere in the method, anonymous classes, and `self`/`static`/`parent`. A Laravel corpus has many identically named `search()`/`log()`/`handle()` methods, so an absent edge is worth more than a guessed one. + --- ## Token benchmark diff --git a/pyproject.toml b/pyproject.toml index 5168ebd3bb..7b8e71551c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.33" +version = "0.9.34" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index 8573a9e9d6..c737c43bf0 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.31" +version = "0.9.34" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From a149cb548de63c3c9996caaedbc177b5f4360b4b Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Thu, 6 Aug 2026 08:17:01 -0300 Subject: [PATCH 13/19] feat(php): capture use FQN/alias/kind metadata on imports edges PHP `imports` edges now carry `use_kind` / `alias` / `target_fqn` metadata, mirroring `_import_csharp`. The already-correct `use`-parser inside `_resolve_php_type_references` was extracted into shared helpers (`_php_use_clause_fact`, `_php_use_clause_context`, `_php_use_declaration_facts`) consumed by both the resolution pass and the capture path, replacing `_import_php`'s lossy `raw.split("\\")[-1]`. Group use `use A\{B, C as X};`, aliases, leading-backslash absolutes and `use function` / `use const` are all handled in one place, so a clause dispatched on its own (as `_import_php` is) can still spell its own FQN by reading the group prefix and keyword off the parent declaration. Strictly metadata-only: no resolver behavior change, `_PHP_CONFIG.import_types` untouched, edge targets still keyed on the imported short name. Full `extract()` output with metadata stripped, before vs after, over a corpus covering plain / aliased / group / aliased-group / `function` / `const` / group-function / group-const / leading-backslash `use`, trait `use`, inheritance, interfaces and a typed member call: 16 nodes / 27 edges, byte-identical (sha256 f6c6168f). Group-form `use function A\{f, g};` and `use const A\{K};` put the keyword on the declaration rather than the clause, so those names enter the class-name map today; that pre-existing bug is deliberately preserved bit-for-bit here via `apply_declaration_kind=False` and fixed in the follow-up commit. The new metadata already reports the correct kind. Note for consumers: the `use_kind` vocabulary is `class`/`function`/`const` with `alias` as a separate key (unlike C#'s `using_kind == "alias"`), and `_resolve_php_type_references` re-points `imports` edges without touching metadata, so `metadata.target_fqn` is the reliable read rather than the target node's label. Tests: 8 new, all through the public `extract()` seam; 7 failed against unfixed code (the 8th is the targets-unchanged guard, green by construction). Full suite 3984 passed / 36 skipped (baseline 3976/36 + 8). Adapted from fork PR https://github.com/lawnstarter/graphify/pull/29 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extract.py | 45 +++++---- graphify/extractors/resolution.py | 144 +++++++++++++++++++++------- tests/test_php_use_imports.py | 152 ++++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+), 51 deletions(-) create mode 100644 tests/test_php_use_imports.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d264a115..26c83830b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: `graphify merge-graphs` no longer drops hyperedges (#2484, thanks @sortakool, and @oleksii-tumanov for the approach in #1691). Hyperedge member ids and ids are now relabeled with the per-repo prefix, both inputs' hyperedges are unioned instead of one clobbering the other, and they are written to both the top-level and nested slots. - Fix: `build_from_json` now reads hyperedges from both the top-level and nested `graph` slots, so label and re-cluster runs no longer silently empty a graph's hyperedge set (#2485, thanks @sortakool); a full validation wipeout is now reported loudly. - Fix: the skill flow now passes the curated community labels to `to_json`, so `graph.json` ships with `community_name` on nodes instead of dropping it (#2490, thanks @PapiScholz). +- PHP `imports` edges now carry `use_kind` (`class`/`function`/`const`), `alias` and `target_fqn` metadata, mirroring the C# `using` capture. The `use`-statement parser is now shared between the capture path and the PHP type-reference pass, so group use `use A\{B, C as X};`, aliases and leading-backslash absolutes are spelled out once. Edge targets are unchanged — they stay keyed on the imported short name — so this is additive for existing consumers; note that the type-reference pass re-points `imports` edges without rewriting metadata, making `metadata.target_fqn` the reliable read rather than the target node's label. ## 0.9.33 (2026-08-05) diff --git a/graphify/extract.py b/graphify/extract.py index dc7540d5fa..cb7d80adc3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -106,6 +106,8 @@ _pascal_resolve_class, _pascal_resolve_unit, _pascal_unit_cache, + _php_use_clause_context, + _php_use_clause_fact, _pnpm_workspace_globs, _python_call_identifier, _python_import_from_module, @@ -662,23 +664,32 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: - for child in node.children: - if child.type in ("qualified_name", "name", "identifier"): - raw = _read_text(child, source) - module_name = raw.split("\\")[-1].strip() - if module_name: - tgt_nid = _make_id(module_name) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "context": "import", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) - break + # `node` is a single `namespace_use_clause`; the group-use prefix and the + # `function`/`const` keyword of a group use live on the parent declaration, + # so the clause alone cannot spell its own FQN. Shared parser with + # `_resolve_php_type_references` — see resolution.py. + fact = _php_use_clause_fact(node, source, *_php_use_clause_context(node, source)) + if fact is None: + return + target_fqn, alias, use_kind = fact + # The edge target stays keyed on the imported short name: re-pointing it is + # the resolvers' job (`_resolve_php_type_references`), not the capture's. + module_name = target_fqn.rsplit("\\", 1)[-1].strip() + if not module_name: + return + edges.append({ + "source": file_nid, + "target": _make_id(module_name), + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + "metadata": sanitize_metadata({k: v for k, v in + {"use_kind": use_kind, "alias": alias, "target_fqn": target_fqn}.items() + if v is not None}), + }) # ── C/C++ function name helpers ─────────────────────────────────────────────── diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 097c32b6a7..7ce1f87364 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2414,6 +2414,108 @@ def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str: return f"{ns}\\{raw}" if ns else raw +# ── Shared PHP `use`-statement parser ───────────────────────────────────────── +# One parser for both consumers: the `uses_by_file` map below and the `imports` +# edge capture in `_import_php` (extract.py). Group use `use A\{B, C as X};`, +# aliases, leading-backslash absolutes and `use function` / `use const` are all +# handled here so neither consumer has to re-derive them. + +def _php_use_clause_fact( + clause, + source: bytes, + prefix: str = "", + kind: str = "class", +) -> tuple[str, str | None, str] | None: + """Parse one ``namespace_use_clause`` into ``(target_fqn, alias, use_kind)``. + + ``prefix`` is the group-use prefix (empty for a standalone clause) and + ``kind`` the declaration-level ``function``/``const`` keyword, if any; a + clause-level keyword overrides it. Returns ``None`` when the clause names + no target (e.g. a parse error). + """ + target: str | None = None + alias: str | None = None + saw_as = False + for c in clause.children: + if c.type in ("function", "const"): + kind = c.type + elif c.type == "as": + saw_as = True + elif c.type in ("qualified_name", "name"): + if saw_as: + alias = _read_text(c, source) + elif target is None: + target = _read_text(c, source) + if not target: + return None + fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") + return fqn, alias, kind + + +def _php_use_clause_context(clause, source: bytes) -> tuple[str, str]: + """``(group prefix, use kind)`` a ``namespace_use_clause`` inherits from its + parent ``namespace_use_declaration``. + + For consumers that are dispatched per clause and never see the declaration + (`_import_php`). The prefix only applies to clauses inside a + ``namespace_use_group``; a standalone clause carries its own full name. + """ + parent = getattr(clause, "parent", None) + in_group = parent is not None and parent.type == "namespace_use_group" + decl = parent.parent if in_group else parent + prefix, kind = "", "class" + if decl is None or decl.type != "namespace_use_declaration": + return prefix, kind + for c in decl.children: + if c.type == "namespace_name" and in_group: + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + kind = c.type + return prefix, kind + + +def _php_use_declaration_facts( + decl, + source: bytes, + *, + apply_declaration_kind: bool = True, +) -> list[tuple[str, str | None, str]]: + """Every ``(target_fqn, alias, use_kind)`` a ``namespace_use_declaration`` declares. + + ``use function A\\f;`` puts the keyword on the *clause*, while + ``use function A\\{f, g};`` puts it on the *declaration*. + ``_resolve_php_type_references`` has only ever honored the clause-level + keyword, so it passes ``apply_declaration_kind=False`` to keep that + behavior byte-identical while this change stays metadata-only. New + consumers should leave it on. + """ + prefix, kind, group = "", "class", None + direct = [] + for c in decl.children: + if c.type == "namespace_name": + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + if apply_declaration_kind: + kind = c.type + elif c.type == "namespace_use_group": + group = c + elif c.type == "namespace_use_clause": + direct.append(c) + + facts: list[tuple[str, str | None, str]] = [] + for c in direct: + fact = _php_use_clause_fact(c, source, "", kind) + if fact: + facts.append(fact) + if group is not None: + for c in group.children: + if c.type == "namespace_use_clause": + fact = _php_use_clause_fact(c, source, prefix, kind) + if fact: + facts.append(fact) + return facts + + def _resolve_php_type_references( per_file: list[dict], paths: list[Path], @@ -2473,27 +2575,6 @@ def _record_raw(relation: str, raw: str) -> None: else: raws.setdefault(key, raw) - def _record_use_clause(clause, prefix: str) -> None: - target = None - alias = None - saw_as = False - for c in clause.children: - if c.type in ("function", "const"): - return # not a class import - if c.type == "as": - saw_as = True - elif c.type in ("qualified_name", "name"): - if saw_as: - alias = _read_text(c, source) - elif target is None: - target = _read_text(c, source) - if not target: - return - fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") - key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() - if key: - uses.setdefault(key, fqn) - def walk(n) -> None: t = n.type if t == "namespace_definition": @@ -2502,19 +2583,14 @@ def walk(n) -> None: namespaces.append(_read_text(c, source)) break elif t == "namespace_use_declaration": - prefix = "" - group = None - for c in n.children: - if c.type == "namespace_name": - prefix = _read_text(c, source) # group-use prefix - elif c.type == "namespace_use_group": - group = c - elif c.type == "namespace_use_clause": - _record_use_clause(c, "") - if group is not None: - for c in group.children: - if c.type == "namespace_use_clause": - _record_use_clause(c, prefix) + for fqn, alias, use_kind in _php_use_declaration_facts( + n, source, apply_declaration_kind=False + ): + if use_kind != "class": + continue # `use function` / `use const` are not class imports + key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() + if key: + uses.setdefault(key, fqn) return elif t == "class_declaration": for child in n.children: diff --git a/tests/test_php_use_imports.py b/tests/test_php_use_imports.py new file mode 100644 index 0000000000..933e9be40e --- /dev/null +++ b/tests/test_php_use_imports.py @@ -0,0 +1,152 @@ +"""PHP `use`-import capture. + +Every assertion goes through the public `extract()` seam. These are +metadata-shape tests: the `imports` edges themselves (their targets) must stay +exactly as they were — only `target_fqn` / `alias` / `use_kind` are new. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _import_facts(result: dict) -> set[tuple[str | None, str | None, str | None]]: + """(target_fqn, alias, use_kind) for every `imports` edge in the graph.""" + facts = set() + for e in result["edges"]: + if e.get("relation") != "imports": + continue + md = e.get("metadata") or {} + facts.add((md.get("target_fqn"), md.get("alias"), md.get("use_kind"))) + return facts + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def test_php_plain_use_captures_target_fqn(tmp_path: Path): + f = _write( + tmp_path / "app/Http/I.php", + " Date: Thu, 6 Aug 2026 08:17:06 -0300 Subject: [PATCH 14/19] fix(php): stop group-form use function/const from claiming class names Group-form `use function A\{f, g};` and `use const A\{K};` put the keyword on the *declaration* node, not the clause, so those names wrongly entered `_resolve_php_type_references`'s class-name map. A group-imported function or constant whose short name was also used in a class position in the same file therefore re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/ `references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. The shared parser added in the previous commit already computed the correct kind behind an `apply_declaration_kind=False` compatibility flag, which existed only to keep that commit metadata-only. This removes the flag and its call site, leaving one code path that always honors the declaration-level keyword, so both spellings agree. Strictly subtractive: it can only remove a class-name claim, never add one. The reference then falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Pre-existing, and rare in practice because it needs the same short name used both as a group-imported function/constant and in a class position within one file. Tests: 4 new, all through the public `extract()` seam, each braced form paired with its semantically equivalent unbraced control; 3 failed against unfixed code, and the over-subtraction guard (`use App\Cms\{Page};` still claims the class name, decoy `App\Models\Page` gets no edge) passes on both sides by design. Full suite 3988 passed / 36 skipped (3984/36 + 4). `grep -rn apply_declaration_kind` across the repo now returns nothing. Adapted from fork PR https://github.com/lawnstarter/graphify/pull/30 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extractors/resolution.py | 16 +-- tests/test_php_group_use_kind.py | 156 ++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 tests/test_php_group_use_kind.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c83830b9..9130e80917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: `graphify merge-graphs` no longer drops hyperedges (#2484, thanks @sortakool, and @oleksii-tumanov for the approach in #1691). Hyperedge member ids and ids are now relabeled with the per-repo prefix, both inputs' hyperedges are unioned instead of one clobbering the other, and they are written to both the top-level and nested slots. - Fix: `build_from_json` now reads hyperedges from both the top-level and nested `graph` slots, so label and re-cluster runs no longer silently empty a graph's hyperedge set (#2485, thanks @sortakool); a full validation wipeout is now reported loudly. - Fix: the skill flow now passes the curated community labels to `to_json`, so `graph.json` ships with `community_name` on nodes instead of dropping it (#2490, thanks @PapiScholz). +- Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name. tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. - PHP `imports` edges now carry `use_kind` (`class`/`function`/`const`), `alias` and `target_fqn` metadata, mirroring the C# `using` capture. The `use`-statement parser is now shared between the capture path and the PHP type-reference pass, so group use `use A\{B, C as X};`, aliases and leading-backslash absolutes are spelled out once. Edge targets are unchanged — they stay keyed on the imported short name — so this is additive for existing consumers; note that the type-reference pass re-points `imports` edges without rewriting metadata, making `metadata.target_fqn` the reliable read rather than the target node's label. ## 0.9.33 (2026-08-05) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 7ce1f87364..f3c7800543 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2477,17 +2477,12 @@ def _php_use_clause_context(clause, source: bytes) -> tuple[str, str]: def _php_use_declaration_facts( decl, source: bytes, - *, - apply_declaration_kind: bool = True, ) -> list[tuple[str, str | None, str]]: """Every ``(target_fqn, alias, use_kind)`` a ``namespace_use_declaration`` declares. ``use function A\\f;`` puts the keyword on the *clause*, while - ``use function A\\{f, g};`` puts it on the *declaration*. - ``_resolve_php_type_references`` has only ever honored the clause-level - keyword, so it passes ``apply_declaration_kind=False`` to keep that - behavior byte-identical while this change stays metadata-only. New - consumers should leave it on. + ``use function A\\{f, g};`` puts it on the *declaration* — both spellings + yield ``use_kind == "function"`` here. """ prefix, kind, group = "", "class", None direct = [] @@ -2495,8 +2490,7 @@ def _php_use_declaration_facts( if c.type == "namespace_name": prefix = _read_text(c, source) elif c.type in ("function", "const"): - if apply_declaration_kind: - kind = c.type + kind = c.type elif c.type == "namespace_use_group": group = c elif c.type == "namespace_use_clause": @@ -2583,9 +2577,7 @@ def walk(n) -> None: namespaces.append(_read_text(c, source)) break elif t == "namespace_use_declaration": - for fqn, alias, use_kind in _php_use_declaration_facts( - n, source, apply_declaration_kind=False - ): + for fqn, alias, use_kind in _php_use_declaration_facts(n, source): if use_kind != "class": continue # `use function` / `use const` are not class imports key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() diff --git a/tests/test_php_group_use_kind.py b/tests/test_php_group_use_kind.py new file mode 100644 index 0000000000..f4f2f3177a --- /dev/null +++ b/tests/test_php_group_use_kind.py @@ -0,0 +1,156 @@ +"""Group-form `use function` / `use const` must not claim class names. + +tree-sitter-php puts the `function` / `const` keyword on the *clause* for the +plain form (`use function A\\f;`) but on the *declaration* for the group form +(`use function A\\{f, g};`). `_resolve_php_type_references` only ever inspected +the clause, so group-form members wrongly entered the per-file class-name map +and re-pointed supertype references onto an FQN-labeled external stub. + +Every assertion goes through the public `extract()` seam, with the semantically +equivalent plain form as the side-by-side control. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def _targets(result: dict, relation: str, source_substr: str) -> list[dict]: + """Target nodes of every `relation` edge coming out of a matching source.""" + return [ + _node_by_id(result, e["target"]) + for e in result["edges"] + if e.get("relation") == relation + and source_substr in e.get("source", "").lower() + ] + + +def test_php_group_use_function_behaves_like_the_plain_form(tmp_path: Path): + # `use function Vendor\Sdk\{Render};` imports a *function*, so `Render` in a + # class position is not an explicitly imported class name. The braced form + # must land exactly where the unbraced control lands: on the bare stub the + # legacy unique-label rewire owns, never on an FQN-labeled external stub. + group = _write( + tmp_path / "app/A/UsesGroup.php", + " Date: Wed, 5 Aug 2026 19:56:00 -0300 Subject: [PATCH 15/19] fix(php): emit indirect_call for first-class callables (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8.1 `$obj->method(...)` creates a Closure — it names the method without invoking it — but the 8.1 grammar reuses `member_call_expression` for it, so the shared `node.type in config.call_types` gate saw an ordinary call and the PHP branch never inspected the `arguments` field. The edge landed as `calls`, claiming control flow transfers at that line. Maintainer decision on #15 (option 2): re-tag as `indirect_call`, the relation this repo already uses for "named but not invoked". No sibling resolver emits `calls` for the equivalent syntax — C# method groups, Java method references and TS bare member references are never captured at all — so PHP was the outlier, and deleting the edge would lose a real dependency that suppression cannot express. Detection is stamped at capture as `fcc` on the raw-call fact, keyed on the argument list being exactly the `...` placeholder: probe-verified on the pinned tree-sitter-php 0.24.1, `m(...)` parses as `arguments: (arguments (variadic_placeholder))` — one named child of that type — while `m()`, `m(1)` and the spread `m(...$args)` do not. `_resolve_php_member_calls` reads the marker and flips only the relation: receiver typing, the single-definition and interface/enum/trait refusals, and the confidence ladder are unchanged. The in-file path (`$this->m(...)` binding to a method in the same file) re-tags too, at unchanged EXTRACTED confidence. Ordinary invocations keep `calls`, and a caller that both invokes and references the same method keeps the `calls` edge regardless of source order — the fcc dedup uses its own pair set, and the cross-file pass sorts direct calls ahead of references. Static (`Helper::fmt(...)`) and plain-function (`strlen(...)`) first-class callables are out of scope: neither resolves to a method target today. Tests: 7 new cases through the public extract() seam — plain, nullsafe and `$this` forms (each with a same-named decoy asserted to get no edge), plus regression guards for the ordinary member call, the ordinary `$this` call, the `...$args` spread, and direct-call precedence. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- docs/how-it-works.md | 2 + graphify/extract.py | 125 +++++++------ graphify/extractors/engine.py | 59 +++++- tests/test_php_first_class_callable.py | 237 +++++++++++++++++++++++++ 5 files changed, 370 insertions(+), 56 deletions(-) create mode 100644 tests/test_php_first_class_callable.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd33f47bf..4a39763322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. -- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`). +- Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. +- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`). - The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. - Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped. - Fix: `graphify path` (and the MCP `shortest_path` tool) now respect edge direction by default instead of running on an undirected view, so a returned path no longer traverses edges backwards (#2487, thanks @luliaz0601). Direction is recovered from the stored `_src`/`_tgt` markers. Pass `--undirected` (CLI) or `undirected=true` (MCP) to search ignoring direction; when no directed path exists the command says so instead of silently returning a reversed one. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3d78c1f9b0..3fa25756b3 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -52,6 +52,8 @@ That rubric describes edges Claude inferred. The per-language member-call resolv **PHP member calls refuse rather than guess.** `$this->prop->method()`, `$obj?->method()`, a typed parameter and a `$var = new T()` local all bind to the receiver's declared type as INFERRED 0.8; `(new Service())->method()` is EXTRACTED 1.0, but only when the namespace written at the call site corroborates the class that was found. When the type is not provably one concrete in-corpus class, no edge is emitted at all — untyped, union- and intersection-typed receivers, receivers typed by an `interface`, `enum` or `trait`, a short name that matches two classes, a method the receiver's class does not declare, chained and array-element receivers, a local rebound or shadowed anywhere in the method, anonymous classes, and `self`/`static`/`parent`. A Laravel corpus has many identically named `search()`/`log()`/`handle()` methods, so an absent edge is worth more than a guessed one. +A PHP 8.1 first-class callable — `$obj->method(...)`, including the nullsafe and `$this` forms — resolves by exactly those rules but is emitted as **`indirect_call`**, not `calls`: the syntax creates a `Closure` and names the method without invoking it, the same shape the resolver already labels `indirect_call` for a callback passed by name. `calls` therefore keeps meaning "control flow transfers here". The spread form `$obj->method(...$args)` is a real invocation and stays `calls`. + --- ## Token benchmark diff --git a/graphify/extract.py b/graphify/extract.py index 42fa1a7af4..ee6b22f7f5 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3160,6 +3160,11 @@ def _resolve_php_member_calls( ambiguous receiver type is skipped rather than falling back to a bare method-name match — a Laravel corpus has many identically named service methods, and guessing between them is worse than an absent edge. + + A raw call marked ``fcc`` is a PHP 8.1 first-class callable + (``$obj->method(...)``): the method is referenced, not invoked, so it + resolves by exactly the rules above but is emitted as ``indirect_call`` + (#15). """ def key(label: str) -> str: # PHP class and method names are case-insensitive. @@ -3240,63 +3245,75 @@ def declared_fqn(type_node: dict | None) -> str | None: return by_name.get(key(type_node.get("label", ""))) 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 raw_call.get("lang") != "php" or not raw_call.get("is_member_call"): + # First-class callables (`fcc`, #15) go last: the sort is stable, so ordinary + # invocations keep source order and claim the (caller, method) pair first. A + # real call outranks a mere reference to the same method, which is also the + # precedence the in-file and cross-file indirect-dispatch passes use. + php_raw_calls = [ + raw_call + for result in per_file + for raw_call in result.get("raw_calls", []) + if raw_call.get("lang") == "php" and raw_call.get("is_member_call") + ] + php_raw_calls.sort(key=lambda raw_call: bool(raw_call.get("fcc"))) + for raw_call in php_raw_calls: + 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 + + if receiver == "this": + exact = True + type_nid = enclosing_type.get(caller) + if not type_nid: 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: + else: + exact = False + type_name = raw_call.get("receiver_type") + if not type_name: + continue # untyped / union-typed / unknown receiver: refuse + if key(type_name) in non_class_type_names: + # An interface names no implementation, a trait is not a + # type, and an enum's methods live on no definition node: + # refuse rather than bind a same-short-named stranger. continue + type_defs = type_def_nids.get(key(type_name), []) + if len(type_defs) != 1: + continue # short name collides across the corpus: refuse + type_nid = type_defs[0] + if receiver == "(new)": + # The class is named in the source; promote to EXTRACTED + # only when the written namespace backs the node we found. + type_node = node_by_id.get(type_nid) + exact = _php_qualified_corroborates( + raw_call.get("receiver_qualified"), + type_node, + declared_fqn(type_node), + ) - if receiver == "this": - exact = True - type_nid = enclosing_type.get(caller) - if not type_nid: - continue - else: - exact = False - type_name = raw_call.get("receiver_type") - if not type_name: - continue # untyped / union-typed / unknown receiver: refuse - if key(type_name) in non_class_type_names: - # An interface names no implementation, a trait is not a - # type, and an enum's methods live on no definition node: - # refuse rather than bind a same-short-named stranger. - continue - type_defs = type_def_nids.get(key(type_name), []) - if len(type_defs) != 1: - continue # short name collides across the corpus: refuse - type_nid = type_defs[0] - if receiver == "(new)": - # The class is named in the source; promote to EXTRACTED - # only when the written namespace backs the node we found. - type_node = node_by_id.get(type_nid) - exact = _php_qualified_corroborates( - raw_call.get("receiver_qualified"), - type_node, - declared_fqn(type_node), - ) - - method_nids = method_index.get((type_nid, key(callee)), set()) - if len(method_nids) != 1: - continue # the typed receiver's class has no such method: refuse - 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" if exact else "INFERRED", - "confidence_score": 1.0 if exact else 0.8, - "source_file": raw_call.get("source_file", ""), - "source_location": raw_call.get("source_location"), - "weight": 1.0, - }) + method_nids = method_index.get((type_nid, key(callee)), set()) + if len(method_nids) != 1: + continue # the typed receiver's class has no such method: refuse + 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, + # A first-class callable NAMES the method without invoking it, so it + # is the repo's `indirect_call`, not `calls` (#15). Everything else + # above — receiver typing, the single-definition and interface/enum/ + # trait refusals, the confidence ladder — is deliberately shared. + "relation": "indirect_call" if raw_call.get("fcc") else "calls", + "context": "call", + "confidence": "EXTRACTED" if exact else "INFERRED", + "confidence_score": 1.0 if exact else 0.8, + "source_file": raw_call.get("source_file", ""), + "source_location": raw_call.get("source_location"), + "weight": 1.0, + }) def _resolve_objc_member_calls( diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index ece300c1dc..db4d3fa6ef 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4338,6 +4338,10 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: seen_call_pairs: set[tuple[str, str]] = set() seen_indirect_pairs: set[tuple[str, str]] = set() # Python indirect_call dedup + # PHP first-class-callable indirect_call dedup (#15), kept apart from + # seen_call_pairs so a `$this->m(...)` reference can never swallow the real + # `$this->m()` call to the same target. + seen_php_fcc_pairs: set[tuple[str, str]] = set() seen_dyn_import_pairs: set[tuple[str, str]] = set() seen_static_ref_pairs: set[tuple[str, str, str]] = set() seen_helper_ref_pairs: set[tuple[str, str, str]] = set() @@ -4570,6 +4574,10 @@ def walk_calls( # short name (for lookup) and the written text (for corroboration). php_inline_new_type: str | None = None php_inline_new_qualified: str | None = None + # PHP 8.1 first-class callable `$obj->method(...)` (#15): a + # reference to the method, not an invocation — re-tagged as + # `indirect_call` below and by the cross-file PHP resolver. + php_fcc: bool = False # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -4740,6 +4748,21 @@ def walk_calls( member_receiver = "(new)" php_inline_new_type = short php_inline_new_qualified = _read_text(cls, source) + # First-class callable `$obj->method(...)` (#15). PHP 8.1 + # reuses member_call_expression for it, so the shared + # `node.type in config.call_types` gate cannot tell it + # from an invocation — the argument list can. Probe- + # verified on the pinned grammar (tree-sitter-php 0.24.1): + # `m(...)` parses as `arguments: (arguments + # (variadic_placeholder))` — exactly one named child of + # that type. `m()`, `m(1)` and the spread `m(...$args)` + # (one `argument` child) do not, so the discriminator is + # unambiguous. + args_node = node.child_by_field_name("arguments") + if args_node is not None: + named_args = args_node.named_children + php_fcc = (len(named_args) == 1 + and named_args[0].type == "variadic_placeholder") 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 @@ -4900,9 +4923,38 @@ def walk_calls( tgt_nid = label_to_nid.get(callee_name) if tgt_nid and tgt_nid != caller_nid: pair = (caller_nid, tgt_nid) - if pair not in seen_call_pairs: + if php_fcc: + # `$this->m(...)` names an in-file method: same target, + # same EXTRACTED confidence, distinct relation (#15). A + # direct call to the target already recorded wins the + # pair — the precedence _emit_indirect_ref already uses. + if (pair not in seen_call_pairs + and pair not in seen_php_fcc_pairs): + seen_php_fcc_pairs.add(pair) + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "indirect_call", + "context": "call", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + elif pair not in seen_call_pairs: seen_call_pairs.add(pair) line = node.start_point[0] + 1 + if pair in seen_php_fcc_pairs: + # An earlier first-class-callable reference claimed + # this pair; the real invocation supersedes it, so + # the outcome does not depend on source order. + seen_php_fcc_pairs.discard(pair) + edges[:] = [ + e for e in edges + if not (e.get("relation") == "indirect_call" + and e.get("source") == caller_nid + and e.get("target") == tgt_nid) + ] edges.append({ "source": caller_nid, "target": tgt_nid, @@ -4958,6 +5010,11 @@ def walk_calls( # stamp the receiver type resolved above (#1682). if config.ts_module == "tree_sitter_php": rc_entry["lang"] = "php" + if php_fcc: + # Marker read by _resolve_php_member_calls, which + # emits `indirect_call` for it under the SAME + # target-resolution and refusal rules (#15). + rc_entry["fcc"] = True if _php_receiver_type: rc_entry["receiver_type"] = _php_receiver_type if php_inline_new_qualified: diff --git a/tests/test_php_first_class_callable.py b/tests/test_php_first_class_callable.py new file mode 100644 index 0000000000..41cffe8545 --- /dev/null +++ b/tests/test_php_first_class_callable.py @@ -0,0 +1,237 @@ +"""PHP 8.1 first-class callables emit ``indirect_call``, not ``calls`` (#15). + +``$obj->method(...)`` creates a ``Closure`` — the method is *named*, not invoked, +so control flow does not transfer at that line. The repo already models +"named but not invoked" as the distinct ``indirect_call`` relation, and PHP only +leaked into ``calls`` because the 8.1 grammar reuses ``member_call_expression`` +for first-class-callable syntax. + +Discriminator (probe-verified on the pinned tree-sitter-php 0.24.1): the +``arguments`` node of ``m(...)`` has exactly one named child, of type +``variadic_placeholder``. ``m()``, ``m(1)`` and ``m(...$args)`` do not. + +Target resolution and the refuse-don't-guess rules are UNCHANGED — only the +relation moves. Every positive test carries a decoy class with an identically +named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _edges(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt, rel): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + edges = { + (edge["source"], edge["target"], edge.get("relation")): edge + for edge in result["edges"] + if edge.get("relation") in ("calls", "indirect_call") + } + return edges, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# Shared service + decoy: both define `search()`, so a bare method-name match +# cannot tell them apart — only the receiver's declared type can. +_SERVICE = " str: + return ( + "leadHunter->search(...);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + + assert (index, service_search, "indirect_call") in edges + assert (index, service_search, "calls") not in edges + assert (index, decoy_search, "indirect_call") not in edges + assert (index, decoy_search, "calls") not in edges + # Target resolution is unchanged: same receiver typing, same confidence as + # the ordinary `$this->leadHunter->search([])` call would get. + edge = edges[(index, service_search, "indirect_call")] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + + +def test_nullsafe_first_class_callable_emits_indirect_call(tmp_path: Path): + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return $this->leadHunter?->search(...);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + + assert (index, service_search, "indirect_call") in edges + assert (index, service_search, "calls") not in edges + assert (index, decoy_search, "indirect_call") not in edges + assert (index, decoy_search, "calls") not in edges + + +def test_first_class_callable_on_this_emits_indirect_call(tmp_path: Path): + """`$this->helper(...)` resolves in-file, so it must re-tag on that path too.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "normalise(...);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + # Decoy: a same-named method in another class must not pick up the edge. + "app/Audit/Normaliser.php": ( + "leadHunter->search(['status' => 'open']);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + edge = edges[(index, service_search, "calls")] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 + assert edge["context"] == "call" + + +def test_ordinary_this_call_still_emits_calls(tmp_path: Path): + """Regression guard for the in-file path: `$this->normalise()` stays EXTRACTED `calls`.""" + edges, r = _edges(tmp_path, { + "app/Http/Controllers/LeadController.php": ( + "normalise([]);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + own = _find(r, ".normalise()", "leadcontroller") + + assert (index, own, "calls") in edges + assert (index, own, "indirect_call") not in edges + assert edges[(index, own, "calls")]["confidence"] == "EXTRACTED" + + +def test_spread_argument_is_not_a_first_class_callable(tmp_path: Path): + """`search(...$args)` IS an invocation — only a bare `...` placeholder re-tags.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$args = []; return $this->leadHunter->search(...$args);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + + +def test_direct_call_wins_over_first_class_callable_to_the_same_method(tmp_path: Path): + """Both forms in one caller: the real invocation keeps the pair (existing + indirect-dispatch precedence), regardless of which appears first.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$f = $this->leadHunter->search(...);\n" + " return $this->leadHunter->search([]);" + ), + "app/Http/Controllers/TeamController.php": ( + "normalise(...);\n" + " return $this->normalise([]);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + + team_index = _find(r, ".index()", "teamcontroller") + own = _find(r, ".normalise()", "teamcontroller") + assert (team_index, own, "calls") in edges + assert (team_index, own, "indirect_call") not in edges From 1ac0c74a072125850893dd93646765aa6f9e1d2e Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 19:57:36 -0300 Subject: [PATCH 16/19] fix(php): refuse the same-file bare-name edge for union/intersection receivers (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User story 11 promised no `calls` edge for a union- or intersection-typed receiver. The cross-file resolver honoured it, but the extractor's legacy in-file bare-name arm did not: `_php_defer` was derived from whether a `receiver_type` had been STAMPED, so an annotation REFUSED by the concrete-type policy looked exactly like no annotation at all. A one-file `private Alpha|Beta $svc; $this->svc->run();` therefore bound to whichever `run()` the file's label index saw last — file order — at EXTRACTED confidence. Pre-existing, not a branch regression: it reproduces at the merge-base 4e7e6b1. The receiver table now distinguishes three states for a key: a concrete type (resolve it), PRESENT-but-None (annotation refused as multi-class, defer), and ABSENT (no annotation, keep today's in-file match). Precedence is concrete > refusal > absent, so a union-typed param later assigned a `new T()` still resolves to T while a poisoned one stays refused. Deletion scope is deliberately narrow, since deferring removes edges that exist today: only union (`A|B`) and intersection (`A&B`) annotations defer — including `A|null`, which is semantically `?A` but parses as a union node. The concrete-type policy's other refusals declare no multiplicity and keep their in-file edge: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched, preserving #2's accepted deviation and user story 9. Named in the CHANGELOG. Tests (all through the `extract()` seam): same-file union and intersection variants for properties, params and a promoted param — the separate-file negatives at tests/test_php_member_calls.py:211 spread `**_CORPUS`, which puts the decoys in other files, so the in-file arm never ran and they passed for the wrong reason; no intersection test existed at all. Plus regression guards that untyped properties/params, `$this->method()` and a `self`-typed property keep their same-file edges, locking the deletion scope. 5 red before the fix, 9 green after. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- graphify/extractors/engine.py | 104 +++++++++++++++---- tests/test_php_member_calls.py | 184 +++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a39763322..38e8be97c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. - Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. -- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`). +- Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. +- Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`). - The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. - Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped. - Fix: `graphify path` (and the MCP `shortest_path` tool) now respect edge direction by default instead of running on an undirected view, so a returned path no longer traverses edges backwards (#2487, thanks @luliaz0601). Direction is recovered from the stored `_src`/`_tgt` markers. Pass `--undirected` (CLI) or `undirected=true` (MCP) to search ignoring direction; when no directed path exists the command says so instead of silently returning a reversed one. diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index db4d3fa6ef..e0c893fea6 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -740,6 +740,29 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: return None +# Type expressions that declare MORE THAN ONE possible class for a receiver: +# union (`A|B`) and intersection (`A&B`) — node names probe-verified against +# tree-sitter-php 0.24.1. +_PHP_MULTI_TYPE_NODES = frozenset({"union_type", "intersection_type"}) + + +def _php_multi_typed_annotation(type_node) -> bool: + """True when a PHP type annotation names more than one candidate class (#9). + + `_php_concrete_type_name` refuses several shapes, and the two reasons for + refusal must be told apart at the call site (user story 11): a receiver whose + annotation is a UNION or INTERSECTION provably has several possible classes, + so an in-file bare-name match can only pick one of them by file order and + must be suppressed. The policy's other refusals — `self`/`static`/`parent`, + primitives, `mixed`/`object`/`iterable`/`callable`, a nullable wrapping more + than one type — declare no such multiplicity, so they deliberately keep + today's in-file edge, exactly like a genuinely untyped receiver (#2's + accepted deviation, user story 9). `self` especially: it names the calling + class, whose methods usually ARE the in-file match. + """ + return type_node is not None and type_node.type in _PHP_MULTI_TYPE_NODES + + # Subtrees that are a DIFFERENT binding scope than the method being scanned: # their assignments must not type the enclosing method's variables. Closures are # deliberately absent — their calls are attributed to the enclosing method, so @@ -761,8 +784,8 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: def _php_method_receiver_types( method_node, source: bytes, - field_types: dict[str, str], -) -> dict[str, str]: + field_types: dict[str, str | None], +) -> dict[str, str | None]: """Build the receiver type table visible to one PHP method (#1682). ``this.`` keys come from the declaring class's typed properties and @@ -777,9 +800,18 @@ def _php_method_receiver_types( target, a list-destructuring element, or a `global`/`static` statement rebinding it to other storage. Poisoning is order-independent, which is why it can be decided from a single unordered walk. + + A key mapped to None is PRESENT but unresolved: its annotation named several + candidate classes (`A|B`, `A&B`), which the call site must tell apart from an + ABSENT key, meaning no annotation at all (#9). Precedence is concrete type > + multi-class refusal > absent, so a union-typed param later assigned a `new T()` + still resolves to T, while a poisoned union-typed one stays refused. """ - table = {f"this.{name}": type_name for name, type_name in field_types.items()} + table: dict[str, str | None] = { + f"this.{name}": type_name for name, type_name in field_types.items() + } method_types: dict[str, str] = {} + multi_typed_params: set[str] = set() ambiguous: set[str] = set() def poison(name: str) -> None: @@ -832,13 +864,17 @@ def new_type_name(node) -> str | None: for param in params.children: if param.type not in ("simple_parameter", "property_promotion_parameter"): continue - type_name = _php_concrete_type_name( - param.child_by_field_name("type"), source - ) + type_node = param.child_by_field_name("type") + type_name = _php_concrete_type_name(type_node, source) name_node = param.child_by_field_name("name") if name_node is not None and type_name: - # Untyped / union / primitive params simply stay unbound. + # Untyped / primitive params simply stay unbound. bind(_read_text(name_node, source).lstrip("$"), type_name) + elif name_node is not None and _php_multi_typed_annotation(type_node): + # A union/intersection param is not BOUND — `bind(None)` would + # poison it, and poisoning is indistinguishable from untyped. + # Mark it instead, and let the merge below apply precedence. + multi_typed_params.add(_read_text(name_node, source).lstrip("$")) body = method_node.child_by_field_name("body") stack = list(body.children) if body is not None else [] @@ -884,6 +920,10 @@ def new_type_name(node) -> str | None: table.update(method_types) for name in ambiguous: table.pop(name, None) + for name in multi_typed_params: + # setdefault, so a concrete type learned from a `new` wins; a poisoned + # name (popped just above) falls back to the multi-class refusal. + table.setdefault(name, None) return table @@ -2711,7 +2751,10 @@ def _extract_generic( csharp_method_scopes: dict[int, tuple[object, str]] = {} # PHP receiver typing (#1682): typed properties and constructor-promoted # params of the declaring class, keyed `this.` per method scope. - php_field_types: dict[str, dict[str, str]] = {} + # `prop -> declared type` per class. A value of None means the annotation was + # PRESENT but named several candidate classes (`A|B`, `A&B`), which the call + # site must tell apart from an ABSENT key = no annotation at all (#9). + php_field_types: dict[str, dict[str, str | None]] = {} php_method_scopes: dict[int, tuple[object, str]] = {} csharp_interface_names: set[str] = set() @@ -3555,8 +3598,12 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # #1682: remember the property's declared type so a later # `$this->prop->method()` resolves against it. Only a single # concrete class name counts — unions/primitives are refused. + # A multi-class annotation is recorded as None so the call site + # can defer rather than bare-name match it (#9); every other + # refusal leaves the property out of the table entirely. type_name = _php_concrete_type_name(c, source) - if type_name: + multi_typed = _php_multi_typed_annotation(c) + if type_name or multi_typed: fields = php_field_types.setdefault(parent_class_nid, {}) for pe in node.children: if pe.type != "property_element": @@ -3896,11 +3943,14 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: type_node = sub break # #1682: a promoted param IS a typed class property — - # record it in the same `this.` receiver table. + # record it in the same `this.` receiver table, + # multi-class annotations included as None (#9). if is_promoted and parent_class_nid: promoted_type = _php_concrete_type_name(type_node, source) v = p.child_by_field_name("name") - if promoted_type and v is not None: + if v is not None and ( + promoted_type or _php_multi_typed_annotation(type_node) + ): php_field_types.setdefault(parent_class_nid, {})[ _read_text(v, source).lstrip("$") ] = promoted_type @@ -4526,7 +4576,8 @@ def walk_calls( caller_nid: str, # Java: flat name -> type. C#: the (scoped bindings, field base) pair # from _csharp_method_receiver_types, resolved positionally (#2472). - receiver_types: dict[str, str] | tuple | None = None, + # PHP entries may map a key to None — see _php_method_receiver_types. + receiver_types: dict[str, str | None] | tuple | None = None, extra_locals: frozenset[str] = frozenset(), ) -> None: if node.type in config.function_boundary_types: @@ -4892,18 +4943,33 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) - # PHP (#1682): defer ONLY when the receiver's type is actually - # known — a typed `$this->prop->m()` must not bare-match an - # unrelated same-named method in this file. Plain `$this->m()` - # and untyped receivers keep today's in-file match, since the - # resolver could add nothing for them anyway. + # PHP (#1682): defer when the receiver's type is actually known — + # a typed `$this->prop->m()` must not bare-match an unrelated + # same-named method in this file. Plain `$this->m()` and untyped + # receivers keep today's in-file match, since the resolver could + # add nothing for them anyway. + # + # ALSO defer when the annotation was PRESENT but named several + # candidate classes (`A|B`, `A&B`) — user story 11 (#9). Such a + # receiver stamps no type, so before this the refusal was + # indistinguishable from "untyped" and the in-file arm bound the + # call to whichever same-named method came last in the file, at + # EXTRACTED confidence. The receiver table encodes the difference: + # a PRESENT key mapped to None is a refused multi-class + # annotation, an ABSENT key is no annotation at all. _php_receiver_type: str | None = None + _php_multi_typed_receiver = False if config.ts_module == "tree_sitter_php": if php_inline_new_type: _php_receiver_type = php_inline_new_type elif member_receiver and member_receiver != "this": - _php_receiver_type = (receiver_types or {}).get(member_receiver) - _php_defer = bool(_php_receiver_type) + _php_types = receiver_types or {} + _php_receiver_type = _php_types.get(member_receiver) + _php_multi_typed_receiver = ( + _php_receiver_type is None + and member_receiver in _php_types + ) + _php_defer = bool(_php_receiver_type) or _php_multi_typed_receiver if _java_defer or _php_defer or ( is_member_call and member_receiver diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index a8ef926fc3..2eb355b259 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -1876,3 +1876,187 @@ def test_legacy_php_interfaces_marker_spelling_is_still_read(tmp_path: Path): go = _find(inc, ".go()", "dispatcher") assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls assert not _notified(inc_calls, go) + + +# ── Same-file union / intersection receivers (user story 11, #9) ────────────── +# +# The separate-file negatives above pass for the wrong reason: with the decoys in +# other files, the extractor's LEGACY in-file bare-name arm never runs, so only +# the cross-file resolver is exercised. When the candidate classes live in the +# SAME file as the call, that arm fires and binds the receiver to whichever +# same-named method the label index saw last — pure file order, stamped +# EXTRACTED. A union or intersection annotation proves the receiver has MORE +# THAN ONE possible class, so the extractor must defer to the resolver (which +# refuses an unstamped receiver) instead. +# +# Deletion scope is deliberately limited to `A|B` / `A&B`. The concrete-type +# policy also refuses `self`/`static`/`parent`, primitives and +# `mixed`/`object`/`iterable`/`callable`, but none of those declares MULTIPLE +# candidate classes — `self` in particular makes the in-file match likely +# correct — so they keep today's edge, exactly like a genuinely untyped receiver. + +def _same_file(receiver_decl: str, *, second_class: str = "", call: str = "$this->svc") -> str: + """One PHP file: candidate class(es) plus a Ctrl whose property is `$svc`.""" + return ( + "run();\n" + " }\n" + "}\n" + ) + + +_BETA = "class Beta { public function run(): int { return 2; } }\n" + + +def _ran(calls, go: str) -> list[str]: + """Every ``calls`` target of ``go``. Each fixture below makes exactly one call + (``->run()``), so the whole target list doubles as the assertion.""" + return sorted(tgt for src, tgt in calls if src == go) + + +def test_same_file_union_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha|Beta $svc` with BOTH candidates in the call's own file.""" + calls, r = _calls(tmp_path, { + "app/U.php": _same_file("private Alpha|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [], \ + "a union-typed receiver has no single class — the in-file bare-name " \ + "match would bind it to one of them by file order" + + +def test_same_file_intersection_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha&Beta $svc`: an intersection is named by user story 11 too, and had + no test at all before this ticket.""" + calls, r = _calls(tmp_path, { + "app/I.php": _same_file("private Alpha&Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/UP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_intersection_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/IP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_promoted_param_emits_no_edge(tmp_path: Path): + """A promoted constructor param is a typed property, reached by the same + `this.` key — the refusal must travel that channel too.""" + calls, r = _calls(tmp_path, { + "app/UPP.php": ( + "svc->run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_untyped_property_keeps_its_edge(tmp_path: Path): + """Regression guard for #2's accepted deviation (user story 9): a receiver + with NO annotation keeps today's same-file bare-name edge. Only an + annotation that was PRESENT and refused as multi-typed defers.""" + calls, r = _calls(tmp_path, { + "app/N.php": _same_file("protected $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_untyped_param_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/NP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_this_call_keeps_its_edge(tmp_path: Path): + """Regression guard (user story 9): `$this->method()` never carries a + receiver type and must stay on the in-file arm.""" + calls, r = _calls(tmp_path, { + "app/T.php": ( + "run(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "ctrl")] + + +def test_same_file_self_typed_property_keeps_its_edge(tmp_path: Path): + """Deletion-scope boundary: `self` is refused by the concrete-type policy but + declares no multiplicity, so it is NOT deferred and keeps today's edge.""" + calls, r = _calls(tmp_path, { + "app/S.php": _same_file("protected self $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] From 90d0425caac824018fb6db2638292818391aefd5 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 19:59:23 -0300 Subject: [PATCH 17/19] fix(php): recognize PHP 8.2 DNF property and promoted-param types (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `private (A&B)|C $x;` parses as a `disjunctive_normal_form_type` node, which neither the property scanner nor the promoted-param scanner named among the type shapes they accept. A DNF-typed property was therefore skipped outright and invisible twice over: * it never reached the receiver type table, so it kept minting the same-file bare-name `calls` edge the previous commit removes — a DNF type is a union at top level, so it has no single receiver class either; * `_php_collect_type_refs` never walked it, so none of its classes got a `references` edge, unlike the plain union property beside it. Naming the node in both scanners fixes both halves at once — they read the same type node, one for the receiver table and one for the reference walk, so the two cannot be separated without a throwaway DNF-only scan. Split out from the union/intersection commit because the reference edges are a behavior addition beyond issue 9's letter. `_php_multi_typed_annotation` gains the node, so DNF refuses exactly like `A|B` does; the deletion scope stated in the previous commit widens by this one shape and the CHANGELOG says so. Test asserts both halves through the `extract()` seam: no `calls` edge, and `references` edges to the DNF's classes. Red before, green after. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + graphify/extractors/engine.py | 16 ++++++++++++---- tests/test_php_member_calls.py | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e8be97c5..f8e2dd674c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. - Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. +- Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. - Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`). - The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. - Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped. diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index e0c893fea6..9c3cbfc2d6 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -741,9 +741,12 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: # Type expressions that declare MORE THAN ONE possible class for a receiver: -# union (`A|B`) and intersection (`A&B`) — node names probe-verified against +# union (`A|B`), intersection (`A&B`) and PHP 8.2 disjunctive-normal-form +# (`(A&B)|C`, a union at top level) — node names probe-verified against # tree-sitter-php 0.24.1. -_PHP_MULTI_TYPE_NODES = frozenset({"union_type", "intersection_type"}) +_PHP_MULTI_TYPE_NODES = frozenset({ + "union_type", "intersection_type", "disjunctive_normal_form_type", +}) def _php_multi_typed_annotation(type_node) -> bool: @@ -3592,7 +3595,11 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: and parent_class_nid): for c in node.children: if c.type not in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + # PHP 8.2 `(A&B)|C`, absent from this list until + # #9 and so invisible to both the receiver table + # and the type-reference walk below. + "disjunctive_normal_form_type"): continue line = node.start_point[0] + 1 # #1682: remember the property's declared type so a later @@ -3939,7 +3946,8 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: type_node = None for sub in p.children: if sub.type in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + "disjunctive_normal_form_type"): type_node = sub break # #1682: a promoted param IS a typed class property — diff --git a/tests/test_php_member_calls.py b/tests/test_php_member_calls.py index 2eb355b259..a9c0ecdfaa 100644 --- a/tests/test_php_member_calls.py +++ b/tests/test_php_member_calls.py @@ -2060,3 +2060,25 @@ def test_same_file_self_typed_property_keeps_its_edge(tmp_path: Path): go = _find(r, ".go()", "ctrl") assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_dnf_typed_property_emits_no_edge_and_references_its_types(tmp_path: Path): + """PHP 8.2 disjunctive normal form (`(A&B)|C`) parses as its own node type, + which the property scanner's type-node list did not name — so a DNF property + reached neither the receiver table (it kept minting the bare-name edge this + ticket removes) nor the type-reference walk (its classes went unreferenced). + It is a union at top level, so it refuses like one, and references like one.""" + calls, r = _calls(tmp_path, { + "app/D.php": _same_file("private (Alpha&Beta)|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + refs = { + (edge["source"], edge["target"]) + for edge in r["edges"] if edge.get("relation") == "references" + } + ctrl = _find(r, "Ctrl", "d_ctrl") + assert (ctrl, _find(r, "Alpha", "d_alpha")) in refs + assert (ctrl, _find(r, "Beta", "d_beta")) in refs From bd2ec679aa08f1ce18ed90ab0f4227da470e3916 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 20:26:51 -0300 Subject: [PATCH 18/19] feat(extract): keep written qualified PHP receiver types (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_php_name_text` flattens every written PHP type annotation to its short name, so `private \Vendor\Sdk\Client $c;` was indistinguishable from `private Client $c;` — the compounding half of the #16 false-edge bug. Only inline-`new` kept the written form (`receiver_qualified`). Thread the written qualified form alongside the short name through the receiver-type table for all four annotation positions that type a receiver — properties, constructor-promoted params, ordinary params and `new`-bound locals — and stamp it on the raw-call fact as `receiver_type_qualified`. The table's values become a `_PhpReceiverType` (short, qualified) pair; `qualified` is set only when the annotation carried a namespace separator, so unqualified annotations produce the facts they produced before. Strictly additive: every decision — binding, poisoning, the #9 multi-class refusal (key present, value None) and the resolver's short-name lookup — is still taken on the short name alone. Two `new`s naming the same short name through different written forms keep today's binding and drop the conflicting qualified evidence rather than poisoning the name. Nothing consults the new field yet; the decisive refusal that closes #16 is #21. Verified beyond the suite: over a PHP corpus exercising all four positions plus unions, inline-`new`, same-short-name decoys and the conflicting-written-forms case, the extract() graph is byte-identical to v8 @ e188ff6. --- graphify/extractors/engine.py | 131 +++++++---- tests/test_php_qualified_receiver_types.py | 254 +++++++++++++++++++++ 2 files changed, 343 insertions(+), 42 deletions(-) create mode 100644 tests/test_php_qualified_receiver_types.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 9c3cbfc2d6..6ea3f4b431 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -9,6 +9,7 @@ from graphify.extractors.resolution import _resolve_js_import_target from graphify.security import sanitize_metadata from pathlib import Path +from typing import NamedTuple def _csharp_namespace_id(dotted_name: str) -> str: @@ -714,29 +715,50 @@ def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[s }) -def _php_concrete_type_name(type_node, source: bytes) -> str | None: - """Single concrete class name of a PHP type expression, or None (= refuse). +class _PhpReceiverType(NamedTuple): + """A receiver's declared type: the short name plus the WRITTEN form (#20). + + `short` is what the resolver has always matched on — namespace-stripped, so + `\\Vendor\\Sdk\\Client` and a bare `Client` are the same key. `qualified` is + the annotation exactly as written, and ONLY when it carries a namespace + separator: an unqualified annotation leaves it None, so its facts stay + identical to the pre-#20 ones. It is independent evidence about WHICH + same-short-named class was meant — the fact the use-import resolver (#21) + needs and the flattening in `_php_name_text` used to destroy. + """ + short: str + qualified: str | None + + +def _php_written_type(name_node, source: bytes) -> _PhpReceiverType | None: + """Pair a PHP `name`/`qualified_name` node's short name with its written text.""" + short = _php_name_text(name_node, source) + if not short or short.lower() in _PHP_NON_CONCRETE_TYPE_NAMES: + return None + written = _read_text(name_node, source) + return _PhpReceiverType(short, written if "\\" in written else None) + + +def _php_concrete_type(type_node, source: bytes) -> _PhpReceiverType | None: + """Single concrete class named by a PHP type expression, or None (= refuse). Deliberately NOT `_php_collect_type_refs`: that helper flattens a union into several refs, whereas a receiver typed `A|B` has no single type and must be - refused. `named_type` yields its namespace-stripped name; a nullable wrapper - around exactly one type unwraps (`?Foo` is still concretely Foo); union, - intersection, primitive and missing types yield None (#1682). + refused. `named_type` yields its name (short + written, #20); a nullable + wrapper around exactly one type unwraps (`?Foo` is still concretely Foo); + union, intersection, primitive and missing types yield None (#1682). """ if type_node is None: return None if type_node.type == "named_type": for c in type_node.children: if c.type in ("name", "qualified_name"): - text = _php_name_text(c, source) - if text and text.lower() not in _PHP_NON_CONCRETE_TYPE_NAMES: - return text - return None + return _php_written_type(c, source) return None if type_node.type in ("optional_type", "nullable_type"): inner = [c for c in type_node.named_children if c.type != "comment"] if len(inner) == 1: - return _php_concrete_type_name(inner[0], source) + return _php_concrete_type(inner[0], source) return None @@ -752,7 +774,7 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None: def _php_multi_typed_annotation(type_node) -> bool: """True when a PHP type annotation names more than one candidate class (#9). - `_php_concrete_type_name` refuses several shapes, and the two reasons for + `_php_concrete_type` refuses several shapes, and the two reasons for refusal must be told apart at the call site (user story 11): a receiver whose annotation is a UNION or INTERSECTION provably has several possible classes, so an in-file bare-name match can only pick one of them by file order and @@ -787,8 +809,8 @@ def _php_multi_typed_annotation(type_node) -> bool: def _php_method_receiver_types( method_node, source: bytes, - field_types: dict[str, str | None], -) -> dict[str, str | None]: + field_types: dict[str, _PhpReceiverType | None], +) -> dict[str, _PhpReceiverType | None]: """Build the receiver type table visible to one PHP method (#1682). ``this.`` keys come from the declaring class's typed properties and @@ -809,11 +831,15 @@ def _php_method_receiver_types( ABSENT key, meaning no annotation at all (#9). Precedence is concrete type > multi-class refusal > absent, so a union-typed param later assigned a `new T()` still resolves to T, while a poisoned union-typed one stays refused. + + A resolved value carries both the short name and the written qualified form + (#20); every decision below is taken on the SHORT name alone, so the table's + keys and their resolved/refused/absent status are exactly what they were. """ - table: dict[str, str | None] = { + table: dict[str, _PhpReceiverType | None] = { f"this.{name}": type_name for name, type_name in field_types.items() } - method_types: dict[str, str] = {} + method_types: dict[str, _PhpReceiverType] = {} multi_typed_params: set[str] = set() ambiguous: set[str] = set() @@ -822,14 +848,20 @@ def poison(name: str) -> None: method_types.pop(name, None) ambiguous.add(name) - def bind(name: str, type_name: str | None) -> None: + def bind(name: str, declared: _PhpReceiverType | None) -> None: if not name or name in ambiguous: return previous = method_types.get(name) - if type_name is None or (previous is not None and previous != type_name): + if declared is None or (previous is not None and previous.short != declared.short): poison(name) + elif previous is not None and previous.qualified != declared.qualified: + # Same short name written two ways (`new Client()` then + # `new \Vendor\Client()`): the SHORT binding is what it always was, + # so keep it. Only the qualified evidence conflicts — drop that + # rather than poison a name today's table still types (#20). + method_types[name] = _PhpReceiverType(previous.short, None) else: - method_types[name] = type_name + method_types[name] = declared def poison_bound_vars(node) -> None: """Poison every ``$var`` named anywhere in a binding-site subtree. @@ -847,18 +879,19 @@ def poison_bound_vars(node) -> None: continue stack.extend(n.children) - def new_type_name(node) -> str | None: - """Class named by an ``object_creation_expression``, or None.""" + def new_type_name(node) -> _PhpReceiverType | None: + """Class named by an ``object_creation_expression``, or None. + + `new self()` / `new static()` need inheritance context the raw-call + facts do not carry, so the non-concrete set refuses them. + """ if node is None or node.type != "object_creation_expression": return None cls = next((c for c in node.named_children if c.type in ("name", "qualified_name")), None) if cls is None: # `new class { … }` names nothing return None - text = _php_name_text(cls, source) - if not text or text.lower() in _PHP_NON_CONCRETE_TYPE_NAMES: - return None # `new self()` / `new static()` need inheritance context - return text + return _php_written_type(cls, source) # Natively typed parameters. `variadic_parameter` is excluded on purpose: # `T ...$xs` binds an ARRAY of T, not a T. @@ -868,11 +901,11 @@ def new_type_name(node) -> str | None: if param.type not in ("simple_parameter", "property_promotion_parameter"): continue type_node = param.child_by_field_name("type") - type_name = _php_concrete_type_name(type_node, source) + declared = _php_concrete_type(type_node, source) name_node = param.child_by_field_name("name") - if name_node is not None and type_name: + if name_node is not None and declared: # Untyped / primitive params simply stay unbound. - bind(_read_text(name_node, source).lstrip("$"), type_name) + bind(_read_text(name_node, source).lstrip("$"), declared) elif name_node is not None and _php_multi_typed_annotation(type_node): # A union/intersection param is not BOUND — `bind(None)` would # poison it, and poisoning is indistinguishable from untyped. @@ -2757,7 +2790,7 @@ def _extract_generic( # `prop -> declared type` per class. A value of None means the annotation was # PRESENT but named several candidate classes (`A|B`, `A&B`), which the call # site must tell apart from an ABSENT key = no annotation at all (#9). - php_field_types: dict[str, dict[str, str | None]] = {} + php_field_types: dict[str, dict[str, _PhpReceiverType | None]] = {} php_method_scopes: dict[int, tuple[object, str]] = {} csharp_interface_names: set[str] = set() @@ -3607,17 +3640,18 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # concrete class name counts — unions/primitives are refused. # A multi-class annotation is recorded as None so the call site # can defer rather than bare-name match it (#9); every other - # refusal leaves the property out of the table entirely. - type_name = _php_concrete_type_name(c, source) + # refusal leaves the property out of the table entirely. A + # resolved type keeps the written qualified form too (#20). + declared = _php_concrete_type(c, source) multi_typed = _php_multi_typed_annotation(c) - if type_name or multi_typed: + if declared or multi_typed: fields = php_field_types.setdefault(parent_class_nid, {}) for pe in node.children: if pe.type != "property_element": continue v = pe.child_by_field_name("name") if v is not None: - fields[_read_text(v, source).lstrip("$")] = type_name + fields[_read_text(v, source).lstrip("$")] = declared refs: list[tuple[str, str]] = [] _php_collect_type_refs(c, source, False, refs) for ref_name, role in refs: @@ -3954,7 +3988,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # record it in the same `this.` receiver table, # multi-class annotations included as None (#9). if is_promoted and parent_class_nid: - promoted_type = _php_concrete_type_name(type_node, source) + promoted_type = _php_concrete_type(type_node, source) v = p.child_by_field_name("name") if v is not None and ( promoted_type or _php_multi_typed_annotation(type_node) @@ -4582,10 +4616,13 @@ def _php_class_const_scope(n) -> str | None: def walk_calls( node, caller_nid: str, - # Java: flat name -> type. C#: the (scoped bindings, field base) pair - # from _csharp_method_receiver_types, resolved positionally (#2472). - # PHP entries may map a key to None — see _php_method_receiver_types. - receiver_types: dict[str, str | None] | tuple | None = None, + # Java values are plain short names; C# is the (scoped bindings, + # field base) pair from _csharp_method_receiver_types, resolved + # positionally (#2472); PHP values are a (short, qualified) pair and + # may be None — see _php_method_receiver_types. Body ids are + # language-disjoint, so one body's table only ever meets its own + # language's reader. + receiver_types: dict[str, str | _PhpReceiverType | None] | tuple | None = None, extra_locals: frozenset[str] = frozenset(), ) -> None: if node.type in config.function_boundary_types: @@ -4966,17 +5003,21 @@ def walk_calls( # a PRESENT key mapped to None is a refused multi-class # annotation, an ABSENT key is no annotation at all. _php_receiver_type: str | None = None + # The annotation as WRITTEN, when it carried a namespace (#20). + # Stamped alongside the short name; nothing decides on it yet. + _php_receiver_qualified: str | None = None _php_multi_typed_receiver = False if config.ts_module == "tree_sitter_php": if php_inline_new_type: _php_receiver_type = php_inline_new_type elif member_receiver and member_receiver != "this": _php_types = receiver_types or {} - _php_receiver_type = _php_types.get(member_receiver) - _php_multi_typed_receiver = ( - _php_receiver_type is None - and member_receiver in _php_types - ) + _php_declared = _php_types.get(member_receiver) + if _php_declared is not None: + _php_receiver_type = _php_declared.short + _php_receiver_qualified = _php_declared.qualified + else: + _php_multi_typed_receiver = member_receiver in _php_types _php_defer = bool(_php_receiver_type) or _php_multi_typed_receiver if _java_defer or _php_defer or ( is_member_call @@ -5091,6 +5132,12 @@ def walk_calls( rc_entry["fcc"] = True if _php_receiver_type: rc_entry["receiver_type"] = _php_receiver_type + if _php_receiver_qualified: + # Written form of the ANNOTATION that typed the + # receiver (#20) — kept apart from the inline-`new` + # `receiver_qualified` below, which is the class + # named at the call itself. + rc_entry["receiver_type_qualified"] = _php_receiver_qualified if php_inline_new_qualified: rc_entry["receiver_qualified"] = php_inline_new_qualified raw_calls.append(rc_entry) diff --git a/tests/test_php_qualified_receiver_types.py b/tests/test_php_qualified_receiver_types.py new file mode 100644 index 0000000000..eac45a6552 --- /dev/null +++ b/tests/test_php_qualified_receiver_types.py @@ -0,0 +1,254 @@ +"""PHP qualified receiver types (#20). + +The extractor used to flatten every written PHP type annotation to its short +name, so `private \\Vendor\\Sdk\\Client $c;` was indistinguishable from +`private Client $c;` — the compounding half of the #16 false-edge bug. This +ticket threads the WRITTEN qualified form alongside the short name for the four +annotation positions that type a receiver (properties, constructor-promoted +params, ordinary params and `new`-bound locals) and stamps it on the raw-call +fact as `receiver_type_qualified`. + +Nothing consults the new field yet — the decisive refusal is #21 — so the +resolution tests here are PARITY tests: the short name still drives every edge, +bit for bit. The fact-shape tests use the per-file `extract_php` seam (the same +one `tests/test_ruby_resolution.py` uses for receiver-type facts), because the +raw-call facts are the extractor's output and never reach `{nodes, edges}`. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract, extract_php + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _facts(tmp_path: Path, body: str) -> list[dict]: + """Raw-call facts of one PHP file (the callee is never defined in it, so + every member call stays unresolved and reaches `raw_calls`).""" + path = _write(tmp_path / "app/Http/Controllers/LeadController.php", body) + return extract_php(path).get("raw_calls", []) + + +def _fact(facts: list[dict], receiver: str) -> dict: + matches = [f for f in facts if f.get("receiver") == receiver] + assert len(matches) == 1, facts + return matches[0] + + +def _class(members: str) -> str: + return ( + "c->send(); }\n" + )) + + fact = _fact(facts, "this.c") + assert fact["callee"] == "send" + assert fact["receiver_type"] == "Client" + assert fact["receiver_type_qualified"] == "\\Vendor\\Sdk\\Client" + + +def test_qualified_promoted_param_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function __construct(private \\App\\Services\\LeadHunterService $svc) {}\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_qualified_param_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function handle(\\App\\Services\\LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_qualified_local_new_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function index(): array {\n" + " $svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_nullable_qualified_property_stamps_qualified(tmp_path: Path): + """`?\\A\\B` unwraps to one concrete type — the qualified form must survive + the unwrap, not just the short name.""" + facts = _facts(tmp_path, _class( + " private ?\\App\\Services\\LeadHunterService $svc;\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_namespace_relative_annotation_stamps_written_form(tmp_path: Path): + """`Services\\X` is qualified but RELATIVE to the current namespace. The + extractor stamps what was written; resolving it is the resolver's job.""" + facts = _facts(tmp_path, _class( + " public function handle(Services\\LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "Services\\LeadHunterService" + + +# ── fact shape: unqualified annotations are unchanged ───────────────────────── + + +def test_unqualified_property_stamps_no_qualified_field(tmp_path: Path): + facts = _facts(tmp_path, _class( + " private LeadHunterService $svc;\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact.get("receiver_type_qualified") is None + + +def test_unqualified_param_and_local_stamp_no_qualified_field(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function handle(LeadHunterService $svc): array {\n" + " $other = new AuditLog();\n" + " return $svc->search([]) + $other->search([]);\n" + " }\n" + )) + + param = _fact(facts, "svc") + assert param["receiver_type"] == "LeadHunterService" + assert param.get("receiver_type_qualified") is None + local = _fact(facts, "other") + assert local["receiver_type"] == "AuditLog" + assert local.get("receiver_type_qualified") is None + + +def test_union_typed_receiver_stamps_neither_field(tmp_path: Path): + """A multi-class annotation is a refusal (#9): it stamps no type, and the + qualified field must not resurrect one.""" + facts = _facts(tmp_path, _class( + " public function handle(\\App\\A|\\App\\B $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert "receiver_type" not in fact + assert "receiver_type_qualified" not in fact + + +def test_same_class_written_two_ways_keeps_short_name(tmp_path: Path): + """Two `new`s naming the same SHORT name through different written forms + used to bind that short name, and still do — the qualified evidence is + contradictory, so it is dropped rather than poisoning the binding.""" + facts = _facts(tmp_path, _class( + " public function index(): array {\n" + " $svc = new LeadHunterService();\n" + " $svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact.get("receiver_type_qualified") is None + + +# ── resolution parity through the public extract() seam ─────────────────────── + +_SERVICE = ( + " str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +def test_qualified_annotation_still_resolves_by_short_name(tmp_path: Path): + """Parity: threading the qualified form changes no edge. The written FQN + names the in-corpus class here, and the resolution is exactly today's — + INFERRED 0.8 off the short name, decoy untouched.""" + calls, r = _extract(tmp_path, { + "app/Services/LeadHunterService.php": _SERVICE, + "app/Audit/AuditLog.php": _DECOY, + "app/Http/Controllers/LeadController.php": ( + "svc->search([]); }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.8 From e1b1650b082eb87b1eaec93e44e7b4ecaa75dc9f Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 20:53:05 -0300 Subject: [PATCH 19/19] fix(php): refuse a member call whose receiver type the file claims (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PHP file that writes `use Vendor\Sdk\Client;` has said which `Client` it means, but `_resolve_php_member_calls` never read `use` statements: it bound the receiver's short type name through a corpus-wide index whose only refusal rule was "more than one candidate", so the lone unrelated `App\Local\Client` satisfied the single-definition guard and minted an INFERRED 0.8 edge into a class the file never imported (#16). `PhpNameResolver` mirrors `CsharpNameResolver`: it answers with a `(node_id, decisive)` verdict built from the `use` metadata on `imports` edges (#19), the declared-FQN payload (#14) and the same type-definition index the fallback uses, and is consulted in FRONT of that fallback exactly like the C# call site. A claimed name that lands on no in-corpus class refuses instead of falling back. Written qualified annotations (#20) resolve the same way, absolute or namespace-relative. Strictly subtractive by construction: every node the resolver returns is looked up under the receiver's WRITTEN short name in the very index the fallback consults, so a positive verdict is always the answer the fallback would have given, and the only behavior change is the refusal. Binding an alias to a class its short name does not name is a recall addition and stays with #22. Verified differentially against v8 over a 34-file corpus of PHP receiver-typing shapes: 3 edges deleted, none added, re-pointed or re-scored. The refusal needs no new persisted marker — the `use` map belongs to the calling file, which an incremental rebuild always re-dispatches — and a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so a class off its PSR-4 path keeps its edge on both paths. This is the fix for #16; the issue stays open for the orchestrator's gate. --- CHANGELOG.md | 5 +- graphify/extract.py | 78 +++---- graphify/extractors/php.py | 279 +++++++++++++++++++++++ tests/test_php_name_resolver.py | 393 ++++++++++++++++++++++++++++++++ 4 files changed, 701 insertions(+), 54 deletions(-) create mode 100644 graphify/extractors/php.py create mode 100644 tests/test_php_name_resolver.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e2dd674c..e5fe393adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Refuse-don't-guess is the policy wherever the receiver's type is not provably one concrete in-corpus class, so all of these deliberately emit NO edge: untyped, union-typed (`A|B`) and intersection-typed (`A&B`) receivers; receivers typed by an `interface`, `enum` or `trait` — none of which mints a definition node, so binding one would pick a same-short-named stranger (the `App\Contracts\Notifier` vs `App\Support\Notifier` collision, or `App\Enums\Status` beside an Eloquent `App\Models\Status`), and that refusal now survives an incremental rebuild by persisting those names on the declaring file's node; a short type name that does not match exactly one class in the corpus; a method the receiver's own class does not declare, so `__call` magic dispatch fabricates nothing; chained (`$this->factory()->method()`) and array-element (`$bag['k']->method()`) receivers; a local rebound to anything but a matching `new`, or rebound to other storage by `global`/`static`; a name shadowed by a closure or arrow-function parameter, a `foreach` target, or list destructuring; anonymous classes (`new class { ... }`); and `self`/`static`/`parent` in type position, which need inheritance context the raw-call facts do not carry. - Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. - Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up. -- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver. +- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. - Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.8 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. - Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. +- Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name (`lawnstarter/graphify#26`). tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. Pre-existing; not a regression from the `use`-metadata capture. +- Fix: a PHP member call through a receiver whose type name the calling file CLAIMED no longer falls back to a same-short-named stranger elsewhere in the corpus (`lawnstarter/graphify#16`). A file that writes `use Vendor\Sdk\Client;` has already said which `Client` it means, but the resolver never read `use` statements — so `private Client $c; $this->c->send();` bound the lone unrelated `App\Local\Client`, which satisfied the single-definition guard, and minted an `INFERRED 0.8` edge into a class the file never imported. A new `PhpNameResolver` (the PHP twin of the C# one) resolves the claimed name against the corpus and is consulted in FRONT of the corpus-wide short-name index, so a claim that lands on no in-corpus class refuses instead of guessing. Annotations written out qualified are read the same way, including the namespace-relative form: inside `namespace App\Http`, `private Local\Client $c;` means `App\Http\Local\Client` and not `App\Local\Client`. **Deletion scope**, stated deliberately because this change only ever removes edges: the ONLY edges removed are `calls`/`indirect_call` edges through a receiver whose short type name the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — where that claim does not name a class in the corpus. Everything else is untouched. An unclaimed short name still resolves through exactly the same fallback as before; `use function` / `use const` claim no class name in either spelling; and a claim is only ever contradicted by hard evidence — the namespace the defining file DECLARES, or that file's PSR-4 path when it was not dispatched this run, where a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so incremental rebuilds agree with full ones instead of quietly deleting more. Binding an alias to the right one of several same-short-named classes is a recall ADDITION and deliberately not part of this change. - Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`). - The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job. - Fix: C# receiver typing no longer drops a true call when a same-named variable is declared untypeably elsewhere in the method (#2472, thanks @JensD-git). Receiver types are now tracked per lexical declaration scope and resolved by the call's position, so a typed `static` local-function parameter keeps resolving even when an `out var` reuses the name in the enclosing body. This fixes a regression from 0.9.32 (#2346). Cross-method independence (#2299) and field-conflict poisoning are unchanged; an `out var` receiver itself remains untyped. @@ -20,7 +22,6 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: `graphify merge-graphs` no longer drops hyperedges (#2484, thanks @sortakool, and @oleksii-tumanov for the approach in #1691). Hyperedge member ids and ids are now relabeled with the per-repo prefix, both inputs' hyperedges are unioned instead of one clobbering the other, and they are written to both the top-level and nested slots. - Fix: `build_from_json` now reads hyperedges from both the top-level and nested `graph` slots, so label and re-cluster runs no longer silently empty a graph's hyperedge set (#2485, thanks @sortakool); a full validation wipeout is now reported loudly. - Fix: the skill flow now passes the curated community labels to `to_json`, so `graph.json` ships with `community_name` on nodes instead of dropping it (#2490, thanks @PapiScholz). -- Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name. tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. - PHP `imports` edges now carry `use_kind` (`class`/`function`/`const`), `alias` and `target_fqn` metadata, mirroring the C# `using` capture. The `use`-statement parser is now shared between the capture path and the PHP type-reference pass, so group use `use A\{B, C as X};`, aliases and leading-backslash absolutes are spelled out once. Edge targets are unchanged — they stay keyed on the imported short name — so this is additive for existing consumers; note that the type-reference pass re-points `imports` edges without rewriting metadata, making `metadata.target_fqn` the reliable read rather than the target node's label. ## 0.9.33 (2026-08-05) diff --git a/graphify/extract.py b/graphify/extract.py index ee6b22f7f5..2f37bad31b 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -47,6 +47,10 @@ from graphify.extractors.json_config import extract_json # noqa: F401 from graphify.extractors.markdown import extract_markdown # noqa: F401 from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 +from graphify.extractors.php import ( # noqa: F401 + PhpNameResolver, + _php_qualified_corroborates, +) from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 @@ -3066,54 +3070,6 @@ def key(label: str) -> str: _OBJC_RESOLVER_SUFFIXES = (".m", ".mm", ".h") -def _php_qualified_corroborates( - qualified: str | None, - type_node: dict | None, - declared_fqn: str | None = None, -) -> bool: - """True when a source-written class name corroborates the resolved node (#1682). - - ``(new \\App\\Services\\Svc())`` names the class outright, but the short-name - lookup that found the node ignored the namespace — so the namespace is - independent evidence, and only a match makes the edge EXTRACTED. - - ``declared_fqn`` is the name the DEFINING FILE declares for that class, - read from its ``namespace`` statement at extraction time (#14). When it is - known the comparison is whole-name: `\\App\\Services\\Client` corroborates - `App\\Services\\Client` and nothing else. Two things that used to promote - now do not — a file whose declared namespace disagrees with its PSR-4 path - (the written name then denotes a class that exists nowhere in the corpus), - and a truncated qualifier like `\\Services\\Client`, which is a DIFFERENT - class from `App\\Services\\Client` but matched as a path tail. - - Without a declaration — the file declares no namespace at all, or the node - came from a prior graph on an incremental run — the node's path is the only - corroborating fact left, and PSR-4 maps ``App\\Services\\Svc`` onto - ``app/Services/Svc.php``; every written segment must line up with the tail - of that path, case-insensitively. - - A BARE name (no namespace segment) corroborates nothing and stays INFERRED; - a namespace that does not line up downgrades rather than refusing, since the - class name itself still resolved unambiguously. - """ - if not qualified or not type_node: - return False - want = [seg.casefold() for seg in str(qualified).split("\\") if seg] - if len(want) < 2: - return False # bare `new Svc()`: no namespace written, no evidence - if declared_fqn: - have = [seg.casefold() for seg in str(declared_fqn).split("\\") if seg] - return want == have # whole name, not a suffix of one - source_file = str(type_node.get("source_file") or "") - parts = [p for p in source_file.replace("\\", "/").split("/") - if p and p not in (".", "..")] - if not parts: - return False - parts[-1] = parts[-1].rsplit(".", 1)[0] # drop the file extension - parts = [p.casefold() for p in parts] - return len(parts) >= len(want) and parts[-len(want):] == want - - _PHP_NON_CLASS_TYPE_MARKERS = ("_php_non_class_types", "_php_interfaces") @@ -3233,6 +3189,14 @@ def key(label: str) -> str: if declared and declared.get("path"): class_fqn_by_file[declared["path"]] = declared.get("classes", {}) + # `use`-import/namespace-aware receiver typing (#21), consulted IN FRONT of + # the corpus-wide short-name index below — the shape of the C# call site in + # `_resolve_csharp_member_calls`. A name the calling file CLAIMS through a + # `use` import or writes out qualified is decided here and never falls back: + # that refusal is the whole fix for #16. It can only delete edges, never add + # or re-point one — see PhpNameResolver. + resolver = PhpNameResolver(all_nodes, all_edges, type_def_nids, class_fqn_by_file) + def declared_fqn(type_node: dict | None) -> str | None: """The namespace-qualified name of ``type_node``'s class, if its file declared one. Absent for a global-namespace class, and for a node @@ -3278,10 +3242,20 @@ def declared_fqn(type_node: dict | None) -> str | None: # type, and an enum's methods live on no definition node: # refuse rather than bind a same-short-named stranger. continue - type_defs = type_def_nids.get(key(type_name), []) - if len(type_defs) != 1: - continue # short name collides across the corpus: refuse - type_nid = type_defs[0] + resolved, decisive = resolver.resolve_type_name( + type_name, + raw_call.get("receiver_type_qualified"), + raw_call.get("source_file", ""), + ) + if resolved: + type_nid = resolved + elif decisive: + continue # the name is claimed and names no in-corpus class (#16) + else: + type_defs = type_def_nids.get(key(type_name), []) + if len(type_defs) != 1: + continue # short name collides across the corpus: refuse + type_nid = type_defs[0] if receiver == "(new)": # The class is named in the source; promote to EXTRACTED # only when the written namespace backs the node we found. diff --git a/graphify/extractors/php.py b/graphify/extractors/php.py new file mode 100644 index 0000000000..093b069d29 --- /dev/null +++ b/graphify/extractors/php.py @@ -0,0 +1,279 @@ +"""PHP cross-file resolution. + +The config-driven PHP *extractor* (``extract_php`` → ``_extract_generic``) still +lives in ``graphify/extract.py``; per ``extractors/MIGRATION.md`` the +config-driven languages cannot be ported one-by-one until the shared +``_extract_generic`` core moves as its own coordinated batch. This module is the +PHP home for the parts that *are* cleanly separable — today, the name-resolution +side of the member-call pass: matching a written class name against a definition +node, and the ``use``-import-aware receiver typing built on top of it. +""" +from __future__ import annotations + +from graphify.extractors.resolution import _php_fqn_from_raw + +# Mirrors `_PHP_RESOLVER_SUFFIXES` in graphify/extract.py. Kept local, like +# `_is_cs_file` in csharp.py, so this module imports nothing from the facade. +_PHP_SOURCE_SUFFIXES = ( + ".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", +) + + +def _is_php_file(value: object) -> bool: + return isinstance(value, str) and value.lower().endswith(_PHP_SOURCE_SUFFIXES) + + +def _metadata(value: object) -> dict: + return value if isinstance(value, dict) else {} + + +def _php_key(name: object) -> str: + """Fold a PHP class name: the language matches them case-insensitively.""" + return str(name).strip().casefold() + + +def _php_qualified_corroborates( + qualified: str | None, + type_node: dict | None, + declared_fqn: str | None = None, +) -> bool: + """True when a source-written class name corroborates the resolved node (#1682). + + ``(new \\App\\Services\\Svc())`` names the class outright, but the short-name + lookup that found the node ignored the namespace — so the namespace is + independent evidence, and only a match makes the edge EXTRACTED. + + ``declared_fqn`` is the name the DEFINING FILE declares for that class, + read from its ``namespace`` statement at extraction time (#14). When it is + known the comparison is whole-name: `\\App\\Services\\Client` corroborates + `App\\Services\\Client` and nothing else. Two things that used to promote + now do not — a file whose declared namespace disagrees with its PSR-4 path + (the written name then denotes a class that exists nowhere in the corpus), + and a truncated qualifier like `\\Services\\Client`, which is a DIFFERENT + class from `App\\Services\\Client` but matched as a path tail. + + Without a declaration — the file declares no namespace at all, or the node + came from a prior graph on an incremental run — the node's path is the only + corroborating fact left, and PSR-4 maps ``App\\Services\\Svc`` onto + ``app/Services/Svc.php``; every written segment must line up with the tail + of that path, case-insensitively. + + A BARE name (no namespace segment) corroborates nothing and stays INFERRED; + a namespace that does not line up downgrades rather than refusing, since the + class name itself still resolved unambiguously. + """ + if not qualified or not type_node: + return False + want = [seg.casefold() for seg in str(qualified).split("\\") if seg] + if len(want) < 2: + return False # bare `new Svc()`: no namespace written, no evidence + if declared_fqn: + have = [seg.casefold() for seg in str(declared_fqn).split("\\") if seg] + return want == have # whole name, not a suffix of one + source_file = str(type_node.get("source_file") or "") + parts = [p for p in source_file.replace("\\", "/").split("/") + if p and p not in (".", "..")] + if not parts: + return False + parts[-1] = parts[-1].rsplit(".", 1)[0] # drop the file extension + parts = [p.casefold() for p in parts] + return len(parts) >= len(want) and parts[-len(want):] == want + + +def _php_fqn_names_another_class( + fqn: str, + type_node: dict | None, + declared_fqn: str | None, +) -> bool: + """True when ``fqn`` provably names something OTHER than ``type_node`` (#21). + + The contrapositive of ``_php_qualified_corroborates``, with one deliberate + difference: *absent* evidence is not a contradiction. Otherwise the same + comparison the inline-`new` promotion already makes — the declared name when + the defining file was dispatched this run, its PSR-4 path when it was not. + + Two shapes carry no evidence either way and so keep the edge: + * a name with no namespace segment (`use Client;` imports from the global + namespace and writes nothing to compare); + * a path with fewer segments than the written name, once the declaration + is unavailable — composer maps a namespace PREFIX onto a directory + (`App\\Domain\\` -> `src/`), and a stripped prefix is indistinguishable + from a different class. Refusing there would delete true edges on + incremental rebuilds only, where the declaration is what is missing; + persisting it for unchanged files is #23. + """ + if type_node is None: + return False + want = [seg for seg in str(fqn).split("\\") if seg] + if len(want) < 2: + return False + if not declared_fqn: + parts = [ + part + for part in str(type_node.get("source_file") or "").replace("\\", "/").split("/") + if part and part not in (".", "..") + ] + if len(parts) < len(want): + return False + return not _php_qualified_corroborates(fqn, type_node, declared_fqn) + + +class PhpNameResolver: + """``use``-import/namespace-aware PHP receiver-type resolution (#21). + + The PHP twin of ``CsharpNameResolver`` (``extractors/csharp.py``), built for + the same reason: ``_resolve_php_member_calls`` bound a receiver's short type + name through a corpus-wide index whose only refusal rule was "more than one + candidate", so a file that writes ``use Vendor\\Sdk\\Client;`` — CLAIMING the + name ``Client`` for a class outside the corpus — still bound the lone + unrelated ``App\\Local\\Client`` and minted a wrong ``INFERRED 0.8`` edge + (#16). Consulted in front of that fallback, this resolver makes the claim + decisive: it refuses instead of guessing. + + Built from graph-stamped facts only — the ``imports`` edges' ``use`` + metadata (#19), the declared-FQN payload of the files dispatched this run + (#14), and the same type-definition index the fallback uses. Nothing is + re-parsed, and no new persisted marker is needed: the ``use`` map belongs to + the CALLING file, which an incremental rebuild always re-dispatches. + + STRICTLY SUBTRACTIVE by construction. Every node this returns is looked up + under the receiver's WRITTEN short name in the very index the fallback + consults, so a positive verdict is always the answer the fallback would have + given; the only behavior change is the refusal. Binding ``use App\\X as Y;`` + to a class the short name ``Y`` does not name, or picking the aliased one of + several same-short-named classes, is a recall ADDITION and belongs to #22. + """ + + def __init__( + self, + all_nodes: list[dict], + all_edges: list[dict], + type_def_nids: dict[str, list[str]], + class_fqn_by_file: dict[str, dict[str, str]] | None = None, + ) -> None: + self.type_def_nids = type_def_nids + self.class_fqn_by_file = class_fqn_by_file or {} + self.node_by_id: dict[str, dict] = { + node["id"]: node + for node in all_nodes + if isinstance(node.get("id"), str) and node.get("id") + } + + # Per file: claimed short name -> imported FQN. Read off the edge + # METADATA, never the target node's label — `_resolve_php_type_references` + # re-points import targets (onto an FQN stub, or onto the in-corpus class + # the unique-label rewire finds), and only the metadata still spells what + # the file actually wrote (#19). + self.uses_by_file: dict[str, dict[str, str]] = {} + for edge in all_edges: + if edge.get("relation") != "imports": + continue + source_file = edge.get("source_file") + if not _is_php_file(source_file): + continue + metadata = _metadata(edge.get("metadata")) + # `use function` / `use const` import no class name, in either + # spelling — the shared parser reports the declaration-level keyword + # of the group form too (#26). + if metadata.get("use_kind") != "class": + continue + target_fqn = metadata.get("target_fqn") + if not isinstance(target_fqn, str) or not target_fqn: + continue + alias = metadata.get("alias") + claimed = _php_key( + alias if isinstance(alias, str) and alias + else target_fqn.rsplit("\\", 1)[-1] + ) + if claimed: + # Two `use`s claiming one name is a PHP fatal error; first wins. + self.uses_by_file.setdefault(source_file, {}).setdefault( + claimed, target_fqn + ) + + # Per file: the namespace its declarations sit in, needed to resolve a + # namespace-RELATIVE annotation (`Local\Client`). Derived from the + # declared-FQN payload, which covers every file dispatched this run — + # and the file that writes the annotation always is one. A file that + # declares two namespaces (a PSR-1 violation) is left out rather than + # answered with one of them. + self.namespace_by_file: dict[str, str] = {} + for path, classes in self.class_fqn_by_file.items(): + namespaces = { + fqn.rsplit("\\", 1)[0] if "\\" in fqn else "" + for fqn in classes.values() + } + if len(namespaces) == 1: + self.namespace_by_file[path] = next(iter(namespaces)) + + def _declared_fqn(self, type_node: dict | None) -> str | None: + """The name the DEFINING file declares for ``type_node``'s class (#14). + + Absent for a global-namespace class, and for a class whose file was not + dispatched this run — an incremental rebuild then falls back to the + PSR-4 path comparison, which is why the refusal needs no new marker. + """ + if not type_node: + return None + by_name = self.class_fqn_by_file.get(str(type_node.get("source_file") or "")) + if not by_name: + return None + return by_name.get(_php_key(type_node.get("label", ""))) + + def _written_fqn(self, written: str, source_file: str) -> str | None: + """The FQN a QUALIFIED written annotation denotes, or None if unknowable. + + PHP resolves `\\A\\B` absolutely, `A\\B` through the `use` map's + group-prefix semantics and otherwise relative to the current namespace — + never against the global namespace. A leading backslash is therefore not + something to assume nor to strip blindly (#20). + """ + raw = written.strip() + if raw.startswith("\\"): + return raw.lstrip("\\") + uses = self.uses_by_file.get(source_file, {}) + if _php_key(raw.split("\\", 1)[0]) in uses: + return _php_fqn_from_raw(raw, "", uses) + namespace = self.namespace_by_file.get(source_file) + if namespace is not None: + return _php_fqn_from_raw(raw, namespace, uses) + if source_file in self.class_fqn_by_file: + return None # two namespaces in one file: refuse to pick one + return _php_fqn_from_raw(raw, "", uses) # the file is global-namespace + + def resolve_type_name( + self, type_name: str, qualified: object, source_file: str + ) -> tuple[str | None, bool]: + """Resolve a receiver's declared type to a definition node, with a verdict. + + Returns ``(node_id, decisive)``: + * ``(nid, True)`` — the file's own naming lands on that definition. + * ``(None, True)`` — the file CLAIMS the name (a `use` import, or a + qualified form written at the annotation) and the claim does not + land on an in-corpus class: refuse, and do NOT let the caller fall + back to the looser corpus-wide bare-name match. This is #16. + * ``(None, False)`` — nothing in the file claims the name; the + caller's existing fallback runs unchanged. + """ + short = _php_key(type_name) + if not short: + return None, False + written = qualified.strip() if isinstance(qualified, str) else "" + if "\\" in written: + fqn = self._written_fqn(written, source_file) + else: + fqn = self.uses_by_file.get(source_file, {}).get(short) + if not fqn: + return None, False + + candidates = self.type_def_nids.get(short, []) + if len(candidates) != 1: + # Nothing in the corpus answers to the written name, or several + # things do and the fallback would refuse too. Either way there is + # no edge to keep — telling the alias's target apart from its + # namesakes is #22's job, and this ticket may only delete. + return None, True + node = self.node_by_id.get(candidates[0]) + if _php_fqn_names_another_class(fqn, node, self._declared_fqn(node)): + return None, True + return candidates[0], True diff --git a/tests/test_php_name_resolver.py b/tests/test_php_name_resolver.py new file mode 100644 index 0000000000..a39690dbcc --- /dev/null +++ b/tests/test_php_name_resolver.py @@ -0,0 +1,393 @@ +"""PHP `use`-import-aware receiver typing: the decisive refusal (#21, closes #16). + +`_resolve_php_member_calls` used to bind a receiver's short type name through a +corpus-wide index whose only refusal rule was "more than one candidate". A file +that writes `use Vendor\\Sdk\\Client;` has CLAIMED the name `Client` for a class +that is not in the corpus at all — but the resolver never saw `use` statements, +so the lone unrelated `App\\Local\\Client` satisfied the single-definition guard +and minted a wrong `INFERRED 0.8` edge. + +`PhpNameResolver` (mirroring `CsharpNameResolver`) answers with a +`(node_id, decisive)` verdict and is consulted IN FRONT of that fallback: a +claimed name that does not land on an in-corpus class refuses instead of falling +back. The pass is strictly SUBTRACTIVE — every verdict it returns positively is +one the fallback would have returned anyway, so it can only delete edges. +Binding an alias to the right one of several same-short-named classes is a +recall ADDITION and belongs to #22, pinned below by +`test_alias_renaming_to_an_unclaimed_short_name_stays_unresolved`. + +Every test goes through the public `extract()` seam, and every positive case +carries a decoy class with an identically named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# The canonical #16 fixture (spec #18 carries the same copy) plus a decoy that +# declares an identically named method: only the receiver's type tells them +# apart, so a bare-name match would light the decoy up. +_CLIENT = ( + " str: + return ( + "c->send(); }\n" + "}\n" + ) + + +def _sends(calls, caller: str) -> list[str]: + return [tgt for src, tgt in calls if src == caller] + + +# ── the refusal: a claimed name that names no in-corpus class ───────────────── + + +def test_use_alias_outside_corpus_emits_no_edge(tmp_path: Path): + """#16, cross-file: `use Vendor\\Sdk\\Client;` claims `Client` for a class + this corpus does not contain. The lone `App\\Local\\Client` is a stranger.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\Client;\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [], "an out-of-corpus `use` target must refuse" + + +def test_use_alias_outside_corpus_emits_no_edge_same_file(tmp_path: Path): + """#16, same file: the stranger is declared in another namespace block of + the calling file, so the same-file matcher could mint the edge too.""" + calls, r = _calls(tmp_path, { + "app/Mixed/Both.php": ( + "c->send(); }\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [], \ + "a same-file stranger must be refused like a cross-file one" + + +def test_group_use_alias_outside_corpus_emits_no_edge(tmp_path: Path): + """The group form carries its FQN on the declaration's prefix (#19); the + claim it makes is the same one.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\{Client};\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_renaming_alias_over_an_out_of_corpus_target_emits_no_edge(tmp_path: Path): + """`use X as Client;` claims the short name `Client` just as firmly.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\Handler as Client;\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_written_fqn_outside_corpus_emits_no_edge(tmp_path: Path): + """The compounding half of #16: an annotation that names the out-of-corpus + class outright, with no `use` statement at all (#20 kept the written form).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="\\Vendor\\Sdk\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_namespace_relative_annotation_outside_corpus_emits_no_edge(tmp_path: Path): + """A written qualified name with no leading backslash is RELATIVE: inside + `App\\Http`, `Local\\Client` means `App\\Http\\Local\\Client`, which exists + nowhere — not `App\\Local\\Client`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="Local\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +# ── the guards: everything the refusal must NOT touch ───────────────────────── + + +def test_in_corpus_use_alias_keeps_its_edge(tmp_path: Path): + """The positive guard: a `use` that names the real in-corpus class binds + exactly as it does today (through the unique-short-name fallback), and the + decoy that merely shares the method name still gets nothing.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use App\\Local\\Client;\n"), + }) + + go = _find(r, ".go()", "_go") + send = _find(r, ".send()", "client") + assert (go, send) in calls + assert calls[(go, send)]["confidence"] == "INFERRED" + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_written_fqn_naming_the_in_corpus_class_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="\\App\\Local\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_namespace_relative_annotation_in_corpus_keeps_its_edge(tmp_path: Path): + """Inside namespace `App`, `Local\\Client` IS `App\\Local\\Client`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/I.php": _caller(annotation="Local\\Client", namespace="App"), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_use_function_import_makes_no_class_claim(tmp_path: Path): + """`use function` / `use const` import no class name (#26), in either + spelling — so they claim nothing and the fallback runs untouched.""" + for index, uses in enumerate(( + "use function Vendor\\Sdk\\Client;\n", + "use function Vendor\\Sdk\\{Client};\n", + "use const Vendor\\Sdk\\Client;\n", + )): + calls, r = _calls(tmp_path / f"case{index}", { + **_CORPUS, "app/Http/I.php": _caller(uses), + }) + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls, uses + assert (go, _find(r, ".send()", "recorder")) not in calls, uses + + +def test_unclaimed_short_name_still_falls_back(tmp_path: Path): + """No `use`, no qualified form: the resolver knows nothing about the name + and the corpus-wide fallback decides, exactly as before.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_alias_renaming_to_an_unclaimed_short_name_stays_unresolved(tmp_path: Path): + """The #21/#22 boundary. `use App\\Local\\Client as Api;` names an in-corpus + class, but the WRITTEN short name is `Api` and nothing in the corpus is + called that, so today's fallback finds nothing and this ticket adds no edge: + binding the alias to `App\\Local\\Client` is #22's recall win.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use App\\Local\\Client as Api;\n", annotation="Api"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +# ── the same verdicts across an incremental rebuild ─────────────────────────── +# +# The `use` map belongs to the CALLING file, which `graphify update`/watch always +# re-dispatch, so the refusal needs no persisted marker (spec #18, decision 3). +# The context below is assembled exactly as `test_php_member_calls.py` mirrors +# watch.py: a field subset of the persisted nodes plus contains/method edges, +# both scoped to the files that are NOT re-extracted. + +_CTX_NODE_FIELDS = ("label", "source_file", "file_type", "type") +_CTX_MARKERS = ("_callable", "_callable_class", "_php_non_class_types", + "_php_interfaces") + + +def _watch_resolution_context(result: dict, unchanged: set[str]): + nodes = [] + for node in result["nodes"]: + if not node.get("id") or node.get("source_file") not in unchanged: + continue + ctx = {"id": node["id"]} + ctx.update({field: node.get(field) for field in _CTX_NODE_FIELDS}) + ctx.update({m: node[m] for m in _CTX_MARKERS if node.get(m)}) + nodes.append(ctx) + edges = [ + { + "source": edge.get("source"), + "target": edge.get("target"), + "relation": edge.get("relation"), + "source_file": edge.get("source_file"), + } + for edge in result["edges"] + if edge.get("relation") in ("contains", "method") + and edge.get("source_file") in unchanged + ] + return nodes, edges + + +def _full_then_incremental(tmp_path: Path, files: dict[str, str], changed: str): + corpus = tmp_path / "corpus" + paths = {} + for name, body in files.items(): + path = corpus / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths[name] = path + + def _calls_of(result): + return { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + + full = extract(list(paths.values()), cache_root=corpus) + paths[changed].write_text( + files[changed].replace("class ", "// touched\nclass ", 1), encoding="utf-8" + ) + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(files) - {changed} + ) + inc = extract( + [paths[changed]], + cache_root=corpus, + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges, + ) + return (_calls_of(full), full), (_calls_of(inc), inc) + + +_INCR_CALLER = "app/Http/I.php" + + +def test_alias_refusal_survives_incremental_rebuild(tmp_path: Path): + """`App\\Local\\Client` is unchanged and therefore not dispatched; the claim + lives in the caller, which is, so the refusal must fire either way.""" + (full_calls, _), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_CORPUS, + _INCR_CALLER: _caller("use Vendor\\Sdk\\Client;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + assert _sends(full_calls, go) == [], "full-build baseline must refuse" + assert _sends(inc_calls, go) == [], \ + "an undispatched defining file must not resurrect the false edge" + + +def test_in_corpus_alias_still_resolves_incrementally(tmp_path: Path): + """Positive control for the test above: the defining file's declared FQN is + unavailable on an incremental run, so the class is corroborated against its + PSR-4 path instead — and the edge survives, decoy still empty.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_CORPUS, + _INCR_CALLER: _caller("use App\\Local\\Client;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + # The target lives in an unchanged file, so its id comes from the full run. + send = _find(full, ".send()", "client") + assert (go, send) in full_calls + assert (go, send) in inc_calls, \ + "a rebuild must keep an edge whose `use` names the in-corpus class" + assert (go, _find(full, ".send()", "recorder")) not in inc_calls + + +def test_non_psr4_layout_keeps_its_edge_incrementally(tmp_path: Path): + """Composer maps a namespace PREFIX onto a directory (`App\\Weird\\` -> + `src/`), so `App\\Weird\\Odd` legitimately lives at `src/Odd.php`. The full + run corroborates the `use` against the namespace that file DECLARES; the + incremental run no longer has the declaration and must not read the shorter + path as a contradiction — a stripped prefix looks exactly like one.""" + caller = ( + "o->ping(); }\n" + "}\n" + ) + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "src/Odd.php": ( + "