From 43cd7a2907eb015e539fb48c41c4528d51b62069 Mon Sep 17 00:00:00 2001 From: Filipe Chagas Date: Wed, 5 Aug 2026 14:30:55 -0300 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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'" },